SaaS-мониторинг Telegram: ядро (модули Cards/Kanban/Pipeline/Tenants/Settings/ Discovery, Api, Infrastructure), сервисы telegram/ai/ml/storage, фронт Vue, контракты и grpc-hosting, деплой-конфиги (dev/prod/observability/CI-раннер), Gitea Actions CI, документация (ТЗ, техдок, api-map, код-стайл, планы, бэклог). Текущее состояние: все этапы роадмапа 0–12 закрыты, сборка 5 sln 0/0, тесты 1340/130/52/38/9 зелёные.
This commit is contained in:
@@ -0,0 +1,26 @@
|
||||
using Deal.Infrastructure.Persistence.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace Deal.Infrastructure.Persistence.Configurations;
|
||||
|
||||
/// <summary>
|
||||
/// EF-конфигурация записи аудита
|
||||
/// </summary>
|
||||
public sealed class AuditLogConfiguration : IEntityTypeConfiguration<AuditLogEntity>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<AuditLogEntity> builder)
|
||||
{
|
||||
builder.ToTable("audit_log", "public");
|
||||
|
||||
builder.HasKey(x => x.Id);
|
||||
|
||||
builder.Property(x => x.At).IsRequired();
|
||||
builder.Property(x => x.ActorType).IsRequired();
|
||||
builder.Property(x => x.EventType).IsRequired();
|
||||
builder.Property(x => x.DetailJson).HasColumnType("text");
|
||||
|
||||
builder.HasIndex(x => x.At);
|
||||
builder.HasIndex(x => new { x.TenantId, x.EventType });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
using Deal.Infrastructure.Persistence.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace Deal.Infrastructure.Persistence.Configurations;
|
||||
|
||||
/// <summary>
|
||||
/// EF-конфигурация карточки канбана
|
||||
/// </summary>
|
||||
public sealed class CardConfiguration : IEntityTypeConfiguration<CardEntity>
|
||||
{
|
||||
// Максимальная длина Col: служебные значения + id доски (короткие строки b_...).
|
||||
private const int ColMaxLength = 200;
|
||||
|
||||
// Максимальная длина вида источника.
|
||||
private const int SourceKindMaxLength = 64;
|
||||
|
||||
public void Configure(EntityTypeBuilder<CardEntity> builder)
|
||||
{
|
||||
builder.ToTable("Cards");
|
||||
|
||||
builder.HasKey(x => x.Id);
|
||||
|
||||
builder.Property(x => x.Col).HasMaxLength(ColMaxLength);
|
||||
|
||||
// JSON-поля храним текстом с сериализованным JSON (как value_json настроек).
|
||||
builder.Property(x => x.StackJson).HasColumnType("text");
|
||||
builder.Property(x => x.ContactsJson).HasColumnType("text");
|
||||
builder.Property(x => x.MatchHitsJson).HasColumnType("text");
|
||||
builder.Property(x => x.LinksJson).HasColumnType("text");
|
||||
builder.Property(x => x.FilesJson).HasColumnType("text");
|
||||
builder.Property(x => x.HistoryJson).HasColumnType("text");
|
||||
builder.Property(x => x.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);
|
||||
builder.HasIndex(x => new { x.Col, x.IsNew });
|
||||
|
||||
// Сортировка пространства «Выбранные» — updated_at DESC.
|
||||
builder.HasIndex(x => x.UpdatedAt).IsDescending();
|
||||
|
||||
builder.Property(x => x.SearchTsv)
|
||||
.HasComputedColumnSql(
|
||||
"to_tsvector('russian', coalesce(\"Title\",'')||' '||coalesce(\"Summary\",'')||' '||coalesce(\"SourceText\",'')||' '||coalesce(\"Contact\",''))",
|
||||
stored: true);
|
||||
builder.HasIndex(x => x.SearchTsv).HasMethod("gin");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
using Deal.Infrastructure.Persistence.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace Deal.Infrastructure.Persistence.Configurations;
|
||||
|
||||
/// <summary>
|
||||
/// EF-конфигурация журнала действий над карточками
|
||||
/// </summary>
|
||||
public sealed class CardMoveConfiguration : IEntityTypeConfiguration<CardMoveEntity>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<CardMoveEntity> builder)
|
||||
{
|
||||
builder.ToTable("CardMoves");
|
||||
|
||||
builder.HasKey(x => x.Id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
using Deal.Infrastructure.Persistence.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace Deal.Infrastructure.Persistence.Configurations;
|
||||
|
||||
/// <summary>
|
||||
/// EF-конфигурация единого контейнера
|
||||
/// </summary>
|
||||
public sealed class ContainerConfiguration : IEntityTypeConfiguration<ContainerEntity>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<ContainerEntity> builder)
|
||||
{
|
||||
builder.ToTable("Containers");
|
||||
|
||||
builder.HasKey(x => x.Id);
|
||||
|
||||
builder.Property(x => x.Kind).HasMaxLength(20);
|
||||
builder.Property(x => x.Space).HasMaxLength(20);
|
||||
|
||||
// JSON-поля храним текстом с сериализованным JSON (конвенция value_json).
|
||||
builder.Property(x => x.RulesJson).HasColumnType("text");
|
||||
builder.Property(x => x.PolicyJson).HasColumnType("text");
|
||||
builder.Property(x => x.Note).HasColumnType("text");
|
||||
|
||||
// Выборка пространства сортируется по позиции; ИИ-предложения — отдельно (как Boards).
|
||||
builder.HasIndex(x => new { x.Space, x.Position });
|
||||
builder.HasIndex(x => new { x.Suggested, x.Position });
|
||||
|
||||
// Полнотекстовый поиск по контейнерам (title/description) — STORED-колонка, как Cards.SearchTsv.
|
||||
builder.Property(x => x.SearchTsv)
|
||||
.HasComputedColumnSql(
|
||||
"to_tsvector('russian', coalesce(\"Name\",'')||' '||coalesce(\"Description\",''))",
|
||||
stored: true);
|
||||
builder.HasIndex(x => x.SearchTsv).HasMethod("gin");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
using Deal.Infrastructure.Persistence.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace Deal.Infrastructure.Persistence.Configurations;
|
||||
|
||||
/// <summary>
|
||||
/// EF-конфигурация дедуп-хэшей пайплайна
|
||||
/// </summary>
|
||||
public sealed class DedupEntryConfiguration : IEntityTypeConfiguration<DedupEntryEntity>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<DedupEntryEntity> builder)
|
||||
{
|
||||
builder.ToTable("DedupEntries");
|
||||
|
||||
builder.HasKey(x => x.Hash);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using Deal.Infrastructure.Persistence.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace Deal.Infrastructure.Persistence.Configurations;
|
||||
|
||||
/// <summary>
|
||||
/// EF-конфигурация каталога диалогов
|
||||
/// </summary>
|
||||
public sealed class DialogConfiguration : IEntityTypeConfiguration<DialogEntity>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<DialogEntity> builder)
|
||||
{
|
||||
builder.ToTable("Dialogs");
|
||||
|
||||
builder.HasKey(x => x.Id);
|
||||
|
||||
builder.Property(x => x.Hue).HasDefaultValue("#666");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
using Deal.Infrastructure.Persistence.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace Deal.Infrastructure.Persistence.Configurations;
|
||||
|
||||
/// <summary>
|
||||
/// EF-конфигурация чёрного списка Discovery
|
||||
/// </summary>
|
||||
public sealed class DiscBlacklistConfiguration : IEntityTypeConfiguration<DiscBlacklistEntity>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<DiscBlacklistEntity> builder)
|
||||
{
|
||||
builder.ToTable("DiscBlacklist");
|
||||
|
||||
builder.HasKey(x => x.DialogId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
using Deal.Infrastructure.Persistence.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace Deal.Infrastructure.Persistence.Configurations;
|
||||
|
||||
/// <summary>
|
||||
/// EF-конфигурация кандидата Discovery
|
||||
/// </summary>
|
||||
public sealed class DiscCandidateConfiguration : IEntityTypeConfiguration<DiscCandidateEntity>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<DiscCandidateEntity> builder)
|
||||
{
|
||||
builder.ToTable("DiscCandidates");
|
||||
|
||||
builder.HasKey(x => x.DialogId);
|
||||
|
||||
builder.Property(x => x.MarksJson).HasColumnType("text");
|
||||
builder.Property(x => x.TopicsJson).HasColumnType("text");
|
||||
|
||||
builder.HasIndex(x => new { x.TaskId, x.Status });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using Deal.Infrastructure.Persistence.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace Deal.Infrastructure.Persistence.Configurations;
|
||||
|
||||
/// <summary>
|
||||
/// EF-конфигурация лога Discovery
|
||||
/// </summary>
|
||||
public sealed class DiscLogConfiguration : IEntityTypeConfiguration<DiscLogEntity>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<DiscLogEntity> builder)
|
||||
{
|
||||
builder.ToTable("DiscLog");
|
||||
|
||||
builder.HasKey(x => x.Id);
|
||||
|
||||
builder.HasIndex(x => new { x.TaskId, x.CreatedAt });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using Deal.Infrastructure.Persistence.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace Deal.Infrastructure.Persistence.Configurations;
|
||||
|
||||
/// <summary>
|
||||
/// EF-конфигурация задачи поиска Discovery
|
||||
/// </summary>
|
||||
public sealed class DiscTaskConfiguration : IEntityTypeConfiguration<DiscTaskEntity>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<DiscTaskEntity> builder)
|
||||
{
|
||||
builder.ToTable("DiscTasks");
|
||||
|
||||
builder.HasKey(x => x.Id);
|
||||
|
||||
builder.Property(x => x.KeywordsJson).HasColumnType("text");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
using Deal.Infrastructure.Persistence.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace Deal.Infrastructure.Persistence.Configurations;
|
||||
|
||||
/// <summary>
|
||||
/// EF-конфигурация глобальной
|
||||
/// </summary>
|
||||
public sealed class GlobalSettingConfiguration : IEntityTypeConfiguration<GlobalSettingEntity>
|
||||
{
|
||||
private const int KeyMaxLength = 200;
|
||||
|
||||
public void Configure(EntityTypeBuilder<GlobalSettingEntity> builder)
|
||||
{
|
||||
builder.ToTable("global_settings", "public");
|
||||
|
||||
builder.HasKey(x => x.Key);
|
||||
|
||||
builder.Property(x => x.Key).HasMaxLength(KeyMaxLength);
|
||||
builder.Property(x => x.Value).HasColumnType("text").IsRequired();
|
||||
builder.Property(x => x.UpdatedAt).IsRequired();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
using Deal.Infrastructure.Persistence.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace Deal.Infrastructure.Persistence.Configurations;
|
||||
|
||||
/// <summary>
|
||||
/// EF-конфигурация приглашения
|
||||
/// </summary>
|
||||
public sealed class InviteConfiguration : IEntityTypeConfiguration<InviteEntity>
|
||||
{
|
||||
private const int EmailMaxLength = 200;
|
||||
|
||||
public void Configure(EntityTypeBuilder<InviteEntity> builder)
|
||||
{
|
||||
builder.ToTable("invites", "public");
|
||||
|
||||
builder.HasKey(x => x.Code);
|
||||
|
||||
builder.Property(x => x.Email).HasMaxLength(EmailMaxLength).IsRequired();
|
||||
builder.Property(x => x.TenantId);
|
||||
builder.Property(x => x.Status).IsRequired().HasDefaultValue("pending");
|
||||
builder.Property(x => x.ExpiresAt).IsRequired();
|
||||
builder.Property(x => x.ActivatedAt);
|
||||
builder.Property(x => x.CreatedAt).IsRequired();
|
||||
|
||||
// Email уникален среди активных (pending) инвайтов: после активации/отзыва/истечения он
|
||||
// освобождается — глобальную уникальность регистрации держит unique-индекс users.Login.
|
||||
builder.HasIndex(x => x.Email).IsUnique().HasFilter("\"Status\" = 'pending'");
|
||||
|
||||
builder.HasOne<OperatorEntity>()
|
||||
.WithMany()
|
||||
.HasForeignKey(x => x.CreatedById)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using Deal.Infrastructure.Persistence.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace Deal.Infrastructure.Persistence.Configurations;
|
||||
|
||||
/// <summary>
|
||||
/// EF-конфигурация комментария карточки
|
||||
/// </summary>
|
||||
public sealed class LeadCommentConfiguration : IEntityTypeConfiguration<LeadCommentEntity>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<LeadCommentEntity> builder)
|
||||
{
|
||||
builder.ToTable("LeadComments");
|
||||
|
||||
builder.HasKey(x => x.Id);
|
||||
|
||||
builder.HasOne<CardEntity>()
|
||||
.WithMany()
|
||||
.HasForeignKey(x => x.CardId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasIndex(x => x.CardId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using Deal.Infrastructure.Persistence.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace Deal.Infrastructure.Persistence.Configurations;
|
||||
|
||||
/// <summary>
|
||||
/// EF-конфигурация очереди обучающих сигналов ML
|
||||
/// </summary>
|
||||
public sealed class MlOutboxConfiguration : IEntityTypeConfiguration<MlOutboxEntity>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<MlOutboxEntity> builder)
|
||||
{
|
||||
builder.ToTable("MlOutbox");
|
||||
|
||||
builder.HasKey(x => x.Id);
|
||||
|
||||
// Очистка/выборка outbox идёт по времени создания.
|
||||
builder.HasIndex(x => x.CreatedAt);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
using Deal.Infrastructure.Persistence.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace Deal.Infrastructure.Persistence.Configurations;
|
||||
|
||||
/// <summary>
|
||||
/// EF-конфигурация оператора
|
||||
/// </summary>
|
||||
public sealed class OperatorConfiguration : IEntityTypeConfiguration<OperatorEntity>
|
||||
{
|
||||
private const int LoginMaxLength = 200;
|
||||
|
||||
public void Configure(EntityTypeBuilder<OperatorEntity> builder)
|
||||
{
|
||||
builder.ToTable("operators", "public");
|
||||
|
||||
builder.HasKey(x => x.Id);
|
||||
|
||||
builder.Property(x => x.Login).HasMaxLength(LoginMaxLength).IsRequired();
|
||||
builder.Property(x => x.PasswordHash).IsRequired();
|
||||
builder.Property(x => x.Status).IsRequired().HasDefaultValue("active");
|
||||
builder.Property(x => x.CreatedAt).IsRequired();
|
||||
|
||||
builder.HasIndex(x => x.Login).IsUnique();
|
||||
}
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
using Deal.Infrastructure.Persistence.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace Deal.Infrastructure.Persistence.Configurations;
|
||||
|
||||
/// <summary>
|
||||
/// EF-конфигурация сессии оператора
|
||||
/// </summary>
|
||||
public sealed class OperatorSessionConfiguration : IEntityTypeConfiguration<OperatorSessionEntity>
|
||||
{
|
||||
private const int TokenHashMaxLength = 64;
|
||||
private const int LoginMaxLength = 200;
|
||||
|
||||
public void Configure(EntityTypeBuilder<OperatorSessionEntity> builder)
|
||||
{
|
||||
builder.ToTable("operator_sessions", "public");
|
||||
|
||||
builder.HasKey(x => x.TokenHash);
|
||||
|
||||
builder.Property(x => x.TokenHash).HasMaxLength(TokenHashMaxLength);
|
||||
builder.Property(x => x.Login).HasMaxLength(LoginMaxLength).IsRequired();
|
||||
builder.Property(x => x.ExpiresAt).IsRequired();
|
||||
builder.Property(x => x.CreatedAt).IsRequired();
|
||||
|
||||
builder.HasIndex(x => x.OperatorId);
|
||||
builder.HasIndex(x => x.ExpiresAt);
|
||||
|
||||
builder.HasOne<OperatorEntity>()
|
||||
.WithMany()
|
||||
.HasForeignKey(x => x.OperatorId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using Deal.Infrastructure.Persistence.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace Deal.Infrastructure.Persistence.Configurations;
|
||||
|
||||
/// <summary>
|
||||
/// EF-конфигурация очереди входящих пайплайна
|
||||
/// </summary>
|
||||
public sealed class QueueItemConfiguration : IEntityTypeConfiguration<QueueItemEntity>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<QueueItemEntity> builder)
|
||||
{
|
||||
builder.ToTable("QueueItems");
|
||||
|
||||
builder.HasKey(x => x.Id);
|
||||
|
||||
builder.Property(x => x.Text).HasColumnType("text");
|
||||
builder.Property(x => x.SourceKey).HasColumnType("text");
|
||||
builder.Property(x => x.SourceJson).HasColumnType("text");
|
||||
builder.Property(x => x.ContentJson).HasColumnType("text");
|
||||
|
||||
// Выборка pump'а идёт по статусу и времени постановки (status='new', лимит 12).
|
||||
builder.HasIndex(x => new { x.Status, x.CreatedAt });
|
||||
|
||||
// Дубль-гвард приёма ищет строку по ключу записи источника.
|
||||
builder.HasIndex(x => x.SourceKey);
|
||||
}
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
using Deal.Infrastructure.Persistence.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace Deal.Infrastructure.Persistence.Configurations;
|
||||
|
||||
/// <summary>
|
||||
/// EF-конфигурация счётчика фиксированного окна
|
||||
/// </summary>
|
||||
public sealed class RateLimitCounterConfiguration : IEntityTypeConfiguration<RateLimitCounterEntity>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<RateLimitCounterEntity> builder)
|
||||
{
|
||||
builder.ToTable("rate_limit_counters", "public");
|
||||
|
||||
builder.HasKey(x => x.Key);
|
||||
builder.Property(x => x.Key).HasColumnType("text");
|
||||
builder.Property(x => x.WindowStart).IsRequired();
|
||||
builder.Property(x => x.ExpiresAt).IsRequired();
|
||||
builder.Property(x => x.Count).IsRequired();
|
||||
|
||||
// Индекс для фоновой уборки устаревших окон (DeleteExpiredAsync по ExpiresAt).
|
||||
builder.HasIndex(x => x.ExpiresAt);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using Deal.Infrastructure.Persistence.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace Deal.Infrastructure.Persistence.Configurations;
|
||||
|
||||
/// <summary>
|
||||
/// EF-конфигурация отсева пайплайна
|
||||
/// </summary>
|
||||
public sealed class RejectedItemConfiguration : IEntityTypeConfiguration<RejectedItemEntity>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<RejectedItemEntity> builder)
|
||||
{
|
||||
builder.ToTable("RejectedItems");
|
||||
|
||||
builder.HasKey(x => x.Id);
|
||||
|
||||
builder.Property(x => x.Text).HasColumnType("text");
|
||||
builder.Property(x => x.Reason).HasColumnType("text");
|
||||
builder.Property(x => x.Kw).HasColumnType("text");
|
||||
builder.Property(x => x.SourceKey).HasColumnType("text");
|
||||
builder.Property(x => x.SourceJson).HasColumnType("text");
|
||||
builder.Property(x => x.ContentJson).HasColumnType("text");
|
||||
|
||||
builder.Property(x => x.SearchTsv)
|
||||
.HasComputedColumnSql("to_tsvector('russian', coalesce(\"Text\",''))", stored: true);
|
||||
|
||||
// Автоочистка старше 3 суток и сортировка списка идут по времени отсева.
|
||||
builder.HasIndex(x => x.RejectedAt);
|
||||
|
||||
builder.HasIndex(x => x.SearchTsv).HasMethod("gin");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
using Deal.Infrastructure.Persistence.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace Deal.Infrastructure.Persistence.Configurations;
|
||||
|
||||
/// <summary>
|
||||
/// EF-конфигурация сессии
|
||||
/// </summary>
|
||||
public sealed class SessionConfiguration : IEntityTypeConfiguration<SessionEntity>
|
||||
{
|
||||
private const int TokenHashMaxLength = 64;
|
||||
private const int LoginMaxLength = 200;
|
||||
|
||||
public void Configure(EntityTypeBuilder<SessionEntity> builder)
|
||||
{
|
||||
builder.ToTable("sessions", "public");
|
||||
|
||||
builder.HasKey(x => x.TokenHash);
|
||||
|
||||
builder.Property(x => x.TokenHash).HasMaxLength(TokenHashMaxLength);
|
||||
builder.Property(x => x.Login).HasMaxLength(LoginMaxLength).IsRequired();
|
||||
builder.Property(x => x.ExpiresAt).IsRequired();
|
||||
builder.Property(x => x.CreatedAt).IsRequired();
|
||||
|
||||
builder.HasIndex(x => x.UserId);
|
||||
builder.HasIndex(x => x.ExpiresAt);
|
||||
|
||||
builder.HasOne<UserEntity>()
|
||||
.WithMany()
|
||||
.HasForeignKey(x => x.UserId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using Deal.Infrastructure.Persistence.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace Deal.Infrastructure.Persistence.Configurations;
|
||||
|
||||
/// <summary>
|
||||
/// EF-конфигурация тенанта
|
||||
/// </summary>
|
||||
public sealed class TenantConfiguration : IEntityTypeConfiguration<TenantEntity>
|
||||
{
|
||||
private const int NameMaxLength = 200;
|
||||
|
||||
public void Configure(EntityTypeBuilder<TenantEntity> builder)
|
||||
{
|
||||
builder.ToTable("tenants", "public");
|
||||
|
||||
builder.HasKey(x => x.Id);
|
||||
|
||||
builder.Property(x => x.Name).HasMaxLength(NameMaxLength).IsRequired();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using Deal.Infrastructure.Persistence.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace Deal.Infrastructure.Persistence.Configurations;
|
||||
|
||||
/// <summary>
|
||||
/// EF-конфигурация лимита тенанта
|
||||
/// </summary>
|
||||
public sealed class TenantLimitConfiguration : IEntityTypeConfiguration<TenantLimitEntity>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<TenantLimitEntity> builder)
|
||||
{
|
||||
builder.ToTable("tenant_limits", "public");
|
||||
|
||||
builder.HasKey(x => x.TenantId);
|
||||
|
||||
builder.Property(x => x.BudgetTokens).IsRequired();
|
||||
builder.Property(x => x.Period).IsRequired().HasDefaultValue("month");
|
||||
builder.Property(x => x.PeriodStart).IsRequired();
|
||||
builder.Property(x => x.UsedTokens).IsRequired();
|
||||
builder.Property(x => x.Warned80).IsRequired();
|
||||
builder.Property(x => x.NotifiedExhausted).IsRequired();
|
||||
builder.Property(x => x.UpdatedAt).IsRequired();
|
||||
|
||||
builder.HasOne<TenantEntity>()
|
||||
.WithMany()
|
||||
.HasForeignKey(x => x.TenantId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
using Deal.Infrastructure.Persistence.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace Deal.Infrastructure.Persistence.Configurations;
|
||||
|
||||
/// <summary>
|
||||
/// EF-конфигурация настройки тенанта
|
||||
/// </summary>
|
||||
public sealed class TenantSettingConfiguration : IEntityTypeConfiguration<TenantSettingEntity>
|
||||
{
|
||||
private const int KeyMaxLength = 200;
|
||||
|
||||
public void Configure(EntityTypeBuilder<TenantSettingEntity> builder)
|
||||
{
|
||||
builder.ToTable("settings");
|
||||
|
||||
builder.HasKey(x => x.Key);
|
||||
|
||||
builder.Property(x => x.Key).HasMaxLength(KeyMaxLength);
|
||||
builder.Property(x => x.ValueJson).HasColumnType("text").IsRequired();
|
||||
builder.Property(x => x.UpdatedAt).IsRequired();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using Deal.Infrastructure.Persistence.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace Deal.Infrastructure.Persistence.Configurations;
|
||||
|
||||
/// <summary>
|
||||
/// EF-конфигурация превью-сообщений
|
||||
/// </summary>
|
||||
public sealed class TgMessageConfiguration : IEntityTypeConfiguration<TgMessageEntity>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<TgMessageEntity> builder)
|
||||
{
|
||||
builder.ToTable("TgMessages");
|
||||
|
||||
builder.HasKey(x => x.Id);
|
||||
|
||||
builder.HasIndex(x => new { x.DialogId, x.MsgAt });
|
||||
}
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
using Deal.Infrastructure.Persistence.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace Deal.Infrastructure.Persistence.Configurations;
|
||||
|
||||
/// <summary>
|
||||
/// EF-конфигурация события расхода токенов
|
||||
/// </summary>
|
||||
public sealed class TokenUsageEventConfiguration : IEntityTypeConfiguration<TokenUsageEventEntity>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<TokenUsageEventEntity> builder)
|
||||
{
|
||||
builder.ToTable("token_usage_events", "public");
|
||||
|
||||
builder.HasKey(x => x.Id);
|
||||
|
||||
builder.Property(x => x.TenantId).IsRequired();
|
||||
builder.Property(x => x.At).IsRequired();
|
||||
builder.Property(x => x.Provider).IsRequired();
|
||||
builder.Property(x => x.Model).IsRequired();
|
||||
builder.Property(x => x.Kind).IsRequired();
|
||||
builder.Property(x => x.PromptTokens).IsRequired();
|
||||
builder.Property(x => x.CompletionTokens).IsRequired();
|
||||
builder.Property(x => x.TotalTokens).IsRequired();
|
||||
builder.Property(x => x.DetailJson).HasColumnType("text");
|
||||
|
||||
builder.HasIndex(x => new { x.TenantId, x.At });
|
||||
builder.HasIndex(x => x.At);
|
||||
|
||||
builder.HasOne<TenantEntity>()
|
||||
.WithMany()
|
||||
.HasForeignKey(x => x.TenantId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using Deal.Infrastructure.Persistence.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace Deal.Infrastructure.Persistence.Configurations;
|
||||
|
||||
/// <summary>
|
||||
/// EF-конфигурация пользователя
|
||||
/// </summary>
|
||||
public sealed class UserConfiguration : IEntityTypeConfiguration<UserEntity>
|
||||
{
|
||||
private const int LoginMaxLength = 200;
|
||||
|
||||
public void Configure(EntityTypeBuilder<UserEntity> builder)
|
||||
{
|
||||
builder.ToTable("users", "public");
|
||||
|
||||
builder.HasKey(x => x.Id);
|
||||
|
||||
builder.Property(x => x.Login).HasMaxLength(LoginMaxLength).IsRequired();
|
||||
builder.Property(x => x.PasswordHash).IsRequired();
|
||||
builder.Property(x => x.Status).IsRequired().HasDefaultValue("active");
|
||||
builder.Property(x => x.CreatedAt).IsRequired().HasDefaultValueSql("now()");
|
||||
|
||||
builder.HasIndex(x => x.Login).IsUnique();
|
||||
builder.HasIndex(x => x.TenantId);
|
||||
|
||||
builder.HasOne<TenantEntity>()
|
||||
.WithMany()
|
||||
.HasForeignKey(x => x.TenantId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
using Deal.Infrastructure.Persistence.Configurations;
|
||||
using Deal.Infrastructure.Persistence.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Deal.Infrastructure.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// Системный DbContext
|
||||
/// </summary>
|
||||
public sealed class DealDbContext(DbContextOptions<DealDbContext> options) : DbContext(options)
|
||||
{
|
||||
public DbSet<TenantEntity> Tenants => Set<TenantEntity>();
|
||||
|
||||
public DbSet<UserEntity> Users => Set<UserEntity>();
|
||||
|
||||
public DbSet<SessionEntity> Sessions => Set<SessionEntity>();
|
||||
|
||||
public DbSet<OperatorEntity> Operators => Set<OperatorEntity>();
|
||||
|
||||
public DbSet<OperatorSessionEntity> OperatorSessions => Set<OperatorSessionEntity>();
|
||||
|
||||
public DbSet<InviteEntity> Invites => Set<InviteEntity>();
|
||||
|
||||
public DbSet<TenantLimitEntity> TenantLimits => Set<TenantLimitEntity>();
|
||||
|
||||
public DbSet<AuditLogEntity> AuditLog => Set<AuditLogEntity>();
|
||||
|
||||
/// <summary>
|
||||
/// История расхода токенов.
|
||||
/// </summary>
|
||||
public DbSet<TokenUsageEventEntity> TokenUsageEvents => Set<TokenUsageEventEntity>();
|
||||
|
||||
/// <summary>
|
||||
/// Счётчики фиксированного окна
|
||||
/// </summary>
|
||||
public DbSet<RateLimitCounterEntity> RateLimitCounters => Set<RateLimitCounterEntity>();
|
||||
|
||||
/// <summary>
|
||||
/// Глобальные (системные) настройки оператора
|
||||
/// </summary>
|
||||
public DbSet<GlobalSettingEntity> GlobalSettings => Set<GlobalSettingEntity>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.ApplyConfiguration(new TenantConfiguration());
|
||||
modelBuilder.ApplyConfiguration(new UserConfiguration());
|
||||
modelBuilder.ApplyConfiguration(new SessionConfiguration());
|
||||
modelBuilder.ApplyConfiguration(new OperatorConfiguration());
|
||||
modelBuilder.ApplyConfiguration(new OperatorSessionConfiguration());
|
||||
modelBuilder.ApplyConfiguration(new InviteConfiguration());
|
||||
modelBuilder.ApplyConfiguration(new TenantLimitConfiguration());
|
||||
modelBuilder.ApplyConfiguration(new AuditLogConfiguration());
|
||||
modelBuilder.ApplyConfiguration(new TokenUsageEventConfiguration());
|
||||
modelBuilder.ApplyConfiguration(new RateLimitCounterConfiguration());
|
||||
modelBuilder.ApplyConfiguration(new GlobalSettingConfiguration());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Design;
|
||||
|
||||
namespace Deal.Infrastructure.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// Фабрика для dotnet-ef
|
||||
/// </summary>
|
||||
public sealed class DealDbDesignTimeFactory : IDesignTimeDbContextFactory<DealDbContext>
|
||||
{
|
||||
public DealDbContext CreateDbContext(string[] args)
|
||||
{
|
||||
string connectionString = Environment.GetEnvironmentVariable("DEAL_PG_CONNECTION")
|
||||
?? "Host=localhost;Port=5433;Database=deal;Username=deal;Password=deal_dev_password";
|
||||
var options = new DbContextOptionsBuilder<DealDbContext>()
|
||||
.UseNpgsql(connectionString)
|
||||
.Options;
|
||||
return new DealDbContext(options);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
namespace Deal.Infrastructure.Persistence.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Запись аудита
|
||||
/// </summary>
|
||||
public sealed class AuditLogEntity
|
||||
{
|
||||
public long Id { get; set; }
|
||||
|
||||
public DateTimeOffset At { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Тип актора: operator|tenant|system.
|
||||
/// </summary>
|
||||
public string ActorType { get; set; } = string.Empty;
|
||||
|
||||
public Guid? ActorId { get; set; }
|
||||
|
||||
public Guid? TenantId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Тип события — строковая константа каталога AuditEvents.
|
||||
/// </summary>
|
||||
public string EventType { get; set; } = string.Empty;
|
||||
|
||||
public string? Ip { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Детали события в JSON
|
||||
/// </summary>
|
||||
public string? DetailJson { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
using NpgsqlTypes;
|
||||
|
||||
namespace Deal.Infrastructure.Persistence.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Карточка канбана
|
||||
/// </summary>
|
||||
public sealed class CardEntity
|
||||
{
|
||||
/// <summary>
|
||||
/// Короткий id карточки
|
||||
/// </summary>
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Колонка карточки
|
||||
/// </summary>
|
||||
public string Col { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Признак новой карточки
|
||||
/// </summary>
|
||||
public bool IsNew { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Признак «создано локально вручную»
|
||||
/// </summary>
|
||||
public bool Local { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Признак «найм/разовое», проставленный эвристикой
|
||||
/// </summary>
|
||||
public bool IsVacancy { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// True, когда тип «найм/разовое» подтверждён ИИ по контексту сообщения.
|
||||
/// </summary>
|
||||
public bool IsVacancyKnown { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Заголовок карточки
|
||||
/// </summary>
|
||||
public string Title { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Краткое содержание карточки
|
||||
/// </summary>
|
||||
public string Summary { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Стек/направления, сериализованные в JSON
|
||||
/// </summary>
|
||||
public string StackJson { get; set; } = "[]";
|
||||
|
||||
/// <summary>
|
||||
/// Нижняя граница бюджета
|
||||
/// </summary>
|
||||
public double? BudgetFrom { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Верхняя граница бюджета
|
||||
/// </summary>
|
||||
public double? BudgetTo { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Валюта бюджета
|
||||
/// </summary>
|
||||
public string BudgetCur { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Сконвертированная нижняя граница бюджета в целевую валюту, либо null.
|
||||
/// </summary>
|
||||
public double? ConvFrom { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Сконвертированная верхняя граница бюджета в целевую валюту, либо null.
|
||||
/// </summary>
|
||||
public double? ConvTo { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Валюта сконвертированного бюджета
|
||||
/// </summary>
|
||||
public string ConvCur { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Контактная строка «как в сообщении»
|
||||
/// </summary>
|
||||
public string Contact { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Квалифицированные контакты, сериализованные в JSON
|
||||
/// </summary>
|
||||
public string ContactsJson { get; set; } = "[]";
|
||||
|
||||
/// <summary>
|
||||
/// Время получения исходного сообщения
|
||||
/// </summary>
|
||||
public DateTimeOffset ReceivedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Вид источника
|
||||
/// </summary>
|
||||
public string SourceKind { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Идентификатор записи в источнике
|
||||
/// </summary>
|
||||
public string SourceExternalId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Ссылка на оригинал в источнике
|
||||
/// </summary>
|
||||
public string SourceOriginRef { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Ссылка на источник, сериализованная в JSON
|
||||
/// </summary>
|
||||
public string SourceJson { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Содержимое источника, сериализованное в JSON
|
||||
/// </summary>
|
||||
public string ContentJson { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Текст источника для полнотекстового поиска
|
||||
/// </summary>
|
||||
public string SourceText { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Предыдущая колонка
|
||||
/// </summary>
|
||||
public string PrevCol { get; set; } = "inbox";
|
||||
|
||||
/// <summary>
|
||||
/// Время помещения в архив
|
||||
/// </summary>
|
||||
public DateTimeOffset? ArchivedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Совпавшие критерии правил при попадании в колонку, сериализованные в JSON
|
||||
/// </summary>
|
||||
public string MatchHitsJson { get; set; } = "[]";
|
||||
|
||||
/// <summary>
|
||||
/// Ссылки карточки, сериализованные в JSON
|
||||
/// </summary>
|
||||
public string LinksJson { get; set; } = "[]";
|
||||
|
||||
/// <summary>
|
||||
/// Файлы карточки, сериализованные в JSON
|
||||
/// </summary>
|
||||
public string FilesJson { get; set; } = "[]";
|
||||
|
||||
/// <summary>
|
||||
/// История движения карточки, сериализованная в JSON
|
||||
/// </summary>
|
||||
public string HistoryJson { get; set; } = "[]";
|
||||
|
||||
/// <summary>
|
||||
/// Текст технического задания по карточке
|
||||
/// </summary>
|
||||
public string TzText { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Время напоминания об отложенной карточке, либо null
|
||||
/// </summary>
|
||||
public DateTimeOffset? ReminderAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Признак «напоминание уже выстрелило»
|
||||
/// </summary>
|
||||
public bool ReminderFired { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Полнотекстовый вектор
|
||||
/// </summary>
|
||||
public NpgsqlTsVector SearchTsv { get; set; } = NpgsqlTsVector.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Время создания карточки.
|
||||
/// </summary>
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Время последнего изменения карточки
|
||||
/// </summary>
|
||||
public DateTimeOffset UpdatedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
namespace Deal.Infrastructure.Persistence.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Запись журнала действий над карточкой
|
||||
/// </summary>
|
||||
public sealed class CardMoveEntity
|
||||
{
|
||||
/// <summary>
|
||||
/// Короткий id записи журнала
|
||||
/// </summary>
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Id карточки (Cards.Id), над которой выполнено действие.
|
||||
/// </summary>
|
||||
public string LeadId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Действие: <c>move|trash|restore|comment</c>
|
||||
/// </summary>
|
||||
public string Action { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Колонка-источник переноса, либо null
|
||||
/// </summary>
|
||||
public string? FromCol { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Колонка-назначение переноса, либо null
|
||||
/// </summary>
|
||||
public string? ToCol { get; set; }
|
||||
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
using NpgsqlTypes;
|
||||
|
||||
namespace Deal.Infrastructure.Persistence.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Единый контейнер карточек
|
||||
/// </summary>
|
||||
public sealed class ContainerEntity
|
||||
{
|
||||
/// <summary>
|
||||
/// Короткий id контейнера
|
||||
/// </summary>
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Имя для отображения
|
||||
/// </summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Описание контейнера
|
||||
/// </summary>
|
||||
public string Description { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Цвет (hex).
|
||||
/// </summary>
|
||||
public string Color { get; set; } = "#818cf8";
|
||||
|
||||
/// <summary>
|
||||
/// Позиция в пространстве
|
||||
/// </summary>
|
||||
public int Position { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Вид контейнера: board
|
||||
/// </summary>
|
||||
public string Kind { get; set; } = "board";
|
||||
|
||||
/// <summary>
|
||||
/// Пространство: dashboard | selected
|
||||
/// </summary>
|
||||
public string Space { get; set; } = "dashboard";
|
||||
|
||||
/// <summary>
|
||||
/// Свёрнутость колонки на дашборде
|
||||
/// </summary>
|
||||
public bool Collapsed { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Признак ИИ-предложения
|
||||
/// </summary>
|
||||
public bool Suggested { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Правила маршрутизации
|
||||
/// </summary>
|
||||
public string RulesJson { get; set; } = "{}";
|
||||
|
||||
/// <summary>
|
||||
/// Заметка контейнера
|
||||
/// </summary>
|
||||
public string Note { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Политика контейнера
|
||||
/// </summary>
|
||||
public string PolicyJson { get; set; } = "{}";
|
||||
|
||||
/// <summary>
|
||||
/// Полнотекстовый вектор поиска по контейнерам
|
||||
/// </summary>
|
||||
public NpgsqlTsVector SearchTsv { get; set; } = NpgsqlTsVector.Empty;
|
||||
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace Deal.Infrastructure.Persistence.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Дедуп-хэш текста сообщения
|
||||
/// </summary>
|
||||
public sealed class DedupEntryEntity
|
||||
{
|
||||
/// <summary>
|
||||
/// Хэш нормализованного текста
|
||||
/// </summary>
|
||||
public string Hash { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Id созданной карточки
|
||||
/// </summary>
|
||||
public string? LeadId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Время записи/занятия хэша.
|
||||
/// </summary>
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
namespace Deal.Infrastructure.Persistence.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Диалог/канал каталога тенанта
|
||||
/// </summary>
|
||||
public sealed class DialogEntity
|
||||
{
|
||||
/// <summary>
|
||||
/// Подписанный id диалога
|
||||
/// </summary>
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Отображаемое имя диалога
|
||||
/// </summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Username (handle) источника; пуст, если нет публичного username.
|
||||
/// </summary>
|
||||
public string Handle { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Тип источника: channel|group|forum|chat
|
||||
/// </summary>
|
||||
public string Kind { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Цвет источника из палитры DIALOG_HUES
|
||||
/// </summary>
|
||||
public string Hue { get; set; } = "#666";
|
||||
|
||||
/// <summary>
|
||||
/// Признак мониторинга
|
||||
/// </summary>
|
||||
public bool Monitor { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Текст последнего принятого сообщения.
|
||||
/// </summary>
|
||||
public string LastText { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Момент последнего принятого сообщения
|
||||
/// </summary>
|
||||
public DateTimeOffset? LastAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Признак «канал разобран».
|
||||
/// </summary>
|
||||
public bool Backfilled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Момент последнего изменения строки
|
||||
/// </summary>
|
||||
public DateTimeOffset UpdatedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
namespace Deal.Infrastructure.Persistence.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Чёрный список Discovery
|
||||
/// </summary>
|
||||
public sealed class DiscBlacklistEntity
|
||||
{
|
||||
/// <summary>
|
||||
/// Подписанный id источника, первичный ключ.
|
||||
/// </summary>
|
||||
public string DialogId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Имя источника
|
||||
/// </summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Причина добавления
|
||||
/// </summary>
|
||||
public string Reason { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Момент первого добавления
|
||||
/// </summary>
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
namespace Deal.Infrastructure.Persistence.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Кандидат задачи Discovery
|
||||
/// </summary>
|
||||
public sealed class DiscCandidateEntity
|
||||
{
|
||||
/// <summary>
|
||||
/// Подписанный id источника
|
||||
/// </summary>
|
||||
public string DialogId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Id задачи поиска, которой принадлежит кандидат.
|
||||
/// </summary>
|
||||
public string TaskId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Отображаемое имя источника
|
||||
/// </summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Username (handle) источника; пуст, если нет публичного username.
|
||||
/// </summary>
|
||||
public string Username { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Тип источника: channel|group|forum.
|
||||
/// </summary>
|
||||
public string Kind { get; set; } = "channel";
|
||||
|
||||
/// <summary>
|
||||
/// Цвет источника из палитры DIALOG_HUES
|
||||
/// </summary>
|
||||
public string Hue { get; set; } = "#666";
|
||||
|
||||
/// <summary>
|
||||
/// Число участников источника; null — неизвестно
|
||||
/// </summary>
|
||||
public int? Participants { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Язык источника: true — русский, false — не русский; null — не определён.
|
||||
/// </summary>
|
||||
public bool? LangRu { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Метки оценки, сериализованные в JSON
|
||||
/// </summary>
|
||||
public string MarksJson { get; set; } = "[]";
|
||||
|
||||
/// <summary>
|
||||
/// Оценка тем форума, сериализованная в JSON
|
||||
/// </summary>
|
||||
public string TopicsJson { get; set; } = "[]";
|
||||
|
||||
/// <summary>
|
||||
/// Доля подходящих сообщений оценки
|
||||
/// </summary>
|
||||
public double? FitRatio { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Статус кандидата
|
||||
/// </summary>
|
||||
public string Status { get; set; } = "new";
|
||||
|
||||
/// <summary>
|
||||
/// Вступили автоматически
|
||||
/// </summary>
|
||||
public bool AutoJoined { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Неудачные авто-вступления подряд.
|
||||
/// </summary>
|
||||
public int JoinFailures { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Момент добавления кандидата
|
||||
/// </summary>
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Момент последнего изменения
|
||||
/// </summary>
|
||||
public DateTimeOffset UpdatedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
namespace Deal.Infrastructure.Persistence.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Лог событий задачи Discovery
|
||||
/// </summary>
|
||||
public sealed class DiscLogEntity
|
||||
{
|
||||
/// <summary>
|
||||
/// Короткий id записи
|
||||
/// </summary>
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Id задачи поиска.
|
||||
/// </summary>
|
||||
public string TaskId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Событие (search|skip|review|join_auto|join_manual|leave|reject|flood|error|done).
|
||||
/// </summary>
|
||||
public string Event { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Текст/детали события.
|
||||
/// </summary>
|
||||
public string Text { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Момент события
|
||||
/// </summary>
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
namespace Deal.Infrastructure.Persistence.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Задача поиска Discovery
|
||||
/// </summary>
|
||||
public sealed class DiscTaskEntity
|
||||
{
|
||||
/// <summary>
|
||||
/// Короткий id задачи
|
||||
/// </summary>
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Название задачи
|
||||
/// </summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Описание ниши/цели
|
||||
/// </summary>
|
||||
public string Description { get; set; } = string.Empty;
|
||||
|
||||
public string KeywordsJson { get; set; } = "[]";
|
||||
|
||||
/// <summary>
|
||||
/// Минимальное число участников источника
|
||||
/// </summary>
|
||||
public int MinSubscribers { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Язык источников
|
||||
/// </summary>
|
||||
public string Lang { get; set; } = "ru";
|
||||
|
||||
/// <summary>
|
||||
/// Порог подходящих сообщений оценки, %
|
||||
/// </summary>
|
||||
public int Threshold { get; set; } = 40;
|
||||
|
||||
/// <summary>
|
||||
/// Размер выборки сообщений при оценке
|
||||
/// </summary>
|
||||
public int SampleSize { get; set; } = 10;
|
||||
|
||||
/// <summary>
|
||||
/// План авто-вступлений
|
||||
/// </summary>
|
||||
public int PlanJoins { get; set; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// Авто-вступления воркером включены.
|
||||
/// </summary>
|
||||
public bool AutoJoin { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Статус задачи: draft|running|paused|done|failed.
|
||||
/// </summary>
|
||||
public string Status { get; set; } = "draft";
|
||||
|
||||
/// <summary>
|
||||
/// Индекс текущего ключа поиска
|
||||
/// </summary>
|
||||
public int SearchIdx { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Проход по всем ключам завершён.
|
||||
/// </summary>
|
||||
public bool SearchDone { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Найдено кандидатов поиском.
|
||||
/// </summary>
|
||||
public int Found { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Оценено/пропущено кандидатов.
|
||||
/// </summary>
|
||||
public int Evaluated { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Вступили (joined ≥ plan_joins → задача done).
|
||||
/// </summary>
|
||||
public int Joined { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Отклонено кандидатов.
|
||||
/// </summary>
|
||||
public int Rejected { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Момент создания задачи
|
||||
/// </summary>
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Момент последнего изменения
|
||||
/// </summary>
|
||||
public DateTimeOffset UpdatedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
namespace Deal.Infrastructure.Persistence.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Глобальная (системная) настройка оператора
|
||||
/// </summary>
|
||||
public sealed class GlobalSettingEntity
|
||||
{
|
||||
public string Key { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Значение настройки, сериализованное в JSON.
|
||||
/// </summary>
|
||||
public string Value { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Время последнего изменения
|
||||
/// </summary>
|
||||
public DateTimeOffset UpdatedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
namespace Deal.Infrastructure.Persistence.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Приглашение на регистрацию
|
||||
/// </summary>
|
||||
public sealed class InviteEntity
|
||||
{
|
||||
/// <summary>
|
||||
/// Одноразовый код приглашения
|
||||
/// </summary>
|
||||
public string Code { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Email приглашённого, нормализованный
|
||||
/// </summary>
|
||||
public string Email { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Целевой тенант; null — при активации создаётся новый тенант.
|
||||
/// </summary>
|
||||
public Guid? TenantId { get; set; }
|
||||
|
||||
public string Status { get; set; } = "pending";
|
||||
|
||||
public DateTimeOffset ExpiresAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Момент активации
|
||||
/// </summary>
|
||||
public DateTimeOffset? ActivatedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Оператор, создавший приглашение.
|
||||
/// </summary>
|
||||
public Guid CreatedById { get; set; }
|
||||
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
namespace Deal.Infrastructure.Persistence.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Комментарий карточки
|
||||
/// </summary>
|
||||
public sealed class LeadCommentEntity
|
||||
{
|
||||
/// <summary>
|
||||
/// Короткий id комментария
|
||||
/// </summary>
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Id карточки (Cards.Id), внешний ключ с каскадным удалением.
|
||||
/// </summary>
|
||||
public string CardId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Автор комментария, отдаётся как <c>by</c>.
|
||||
/// </summary>
|
||||
public string By { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Текст комментария.
|
||||
/// </summary>
|
||||
public string Text { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Время добавления комментария
|
||||
/// </summary>
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
namespace Deal.Infrastructure.Persistence.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Строка очереди обучающих сигналов ML
|
||||
/// </summary>
|
||||
public sealed class MlOutboxEntity
|
||||
{
|
||||
/// <summary>
|
||||
/// Короткий id записи outbox
|
||||
/// </summary>
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Текст обучающего примера
|
||||
/// </summary>
|
||||
public string Text { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Метка обучения: id доски
|
||||
/// </summary>
|
||||
public string Label { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Весовой коэффициент сигнала
|
||||
/// </summary>
|
||||
public double Delta { get; set; } = 1.0;
|
||||
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
namespace Deal.Infrastructure.Persistence.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Оператор (администратор SaaS-контура) в системной схеме public.
|
||||
/// </summary>
|
||||
public sealed class OperatorEntity
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
|
||||
/// <summary>
|
||||
/// Логин оператора
|
||||
/// </summary>
|
||||
public string Login { get; set; } = string.Empty;
|
||||
|
||||
public string PasswordHash { get; set; } = string.Empty;
|
||||
|
||||
public string Status { get; set; } = "active";
|
||||
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
namespace Deal.Infrastructure.Persistence.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Сессия оператора в системной схеме public.
|
||||
/// </summary>
|
||||
public sealed class OperatorSessionEntity
|
||||
{
|
||||
/// <summary>
|
||||
/// SHA-256-хеш токена сессии оператора
|
||||
/// </summary>
|
||||
public string TokenHash { get; set; } = string.Empty;
|
||||
|
||||
public Guid OperatorId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Денормализованный логин оператора — для чтения /me без join.
|
||||
/// </summary>
|
||||
public string Login { get; set; } = string.Empty;
|
||||
|
||||
public DateTimeOffset ExpiresAt { get; set; }
|
||||
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
namespace Deal.Infrastructure.Persistence.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Строка очереди входящих пайплайна
|
||||
/// </summary>
|
||||
public sealed class QueueItemEntity
|
||||
{
|
||||
/// <summary>
|
||||
/// Короткий id строки очереди
|
||||
/// </summary>
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Ключ дедупликации записи источника
|
||||
/// </summary>
|
||||
public string SourceKey { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Ссылка на источник, сериализованная в JSON
|
||||
/// </summary>
|
||||
public string SourceJson { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Содержимое источника, сериализованное в JSON
|
||||
/// </summary>
|
||||
public string ContentJson { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Текст сообщения
|
||||
/// </summary>
|
||||
public string Text { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Время получения исходного сообщения
|
||||
/// </summary>
|
||||
public DateTimeOffset MsgAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Статус строки: <c>new</c> — ждёт разбора воркером, <c>filtered</c> — прошла фильтры и ждёт ИИ/ML.
|
||||
/// </summary>
|
||||
public string Status { get; set; } = "new";
|
||||
|
||||
/// <summary>
|
||||
/// Признак возврата из отсева
|
||||
/// </summary>
|
||||
public bool Force { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Время постановки в очередь.
|
||||
/// </summary>
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Время последнего изменения строки.
|
||||
/// </summary>
|
||||
public DateTimeOffset UpdatedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
namespace Deal.Infrastructure.Persistence.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Счётчик фиксированного окна в системной схеме public.
|
||||
/// </summary>
|
||||
public sealed class RateLimitCounterEntity
|
||||
{
|
||||
/// <summary>
|
||||
/// Уникальный ключ счётчика
|
||||
/// </summary>
|
||||
public string Key { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Начало текущего фиксированного окна
|
||||
/// </summary>
|
||||
public DateTimeOffset WindowStart { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Момент, после которого строка считается устаревшей
|
||||
/// </summary>
|
||||
public DateTimeOffset ExpiresAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Число потреблённых единиц в текущем окне.
|
||||
/// </summary>
|
||||
public int Count { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
using NpgsqlTypes;
|
||||
|
||||
namespace Deal.Infrastructure.Persistence.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Запись отсева пайплайна
|
||||
/// </summary>
|
||||
public sealed class RejectedItemEntity
|
||||
{
|
||||
/// <summary>
|
||||
/// Короткий id записи
|
||||
/// </summary>
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Ключ дедупликации записи источника
|
||||
/// </summary>
|
||||
public string SourceKey { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Ссылка на источник, сериализованная в JSON
|
||||
/// </summary>
|
||||
public string SourceJson { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Содержимое источника, сериализованное в JSON
|
||||
/// </summary>
|
||||
public string ContentJson { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Текст сообщения
|
||||
/// </summary>
|
||||
public string Text { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Этап отсева (<c>length|stop|resume|type|budget|stale|dup|spam_ml|spam_ai|filter_ai</c> и т.п.).
|
||||
/// </summary>
|
||||
public string Stage { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Человекочитаемая причина отсева
|
||||
/// </summary>
|
||||
public string Reason { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Совпавшее ключевое слово/фраза правила
|
||||
/// </summary>
|
||||
public string Kw { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Кто вынес решение
|
||||
/// </summary>
|
||||
public string Source { get; set; } = "stop";
|
||||
|
||||
/// <summary>
|
||||
/// Время получения исходного сообщения
|
||||
/// </summary>
|
||||
public DateTimeOffset MsgAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Время записи в отсев
|
||||
/// </summary>
|
||||
public DateTimeOffset RejectedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Признак возврата записи в обработку пользователем.
|
||||
/// </summary>
|
||||
public bool Returned { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Время возврата в обработку, либо null.
|
||||
/// </summary>
|
||||
public DateTimeOffset? ReturnedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Причина возврата пользователем
|
||||
/// </summary>
|
||||
public string ReturnReason { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Полнотекстовый вектор
|
||||
/// </summary>
|
||||
public NpgsqlTsVector SearchTsv { get; set; } = NpgsqlTsVector.Empty;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
namespace Deal.Infrastructure.Persistence.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Сессия пользователя в системной схеме public.
|
||||
/// </summary>
|
||||
public sealed class SessionEntity
|
||||
{
|
||||
/// <summary>
|
||||
/// SHA-256-хеш токена сессии
|
||||
/// </summary>
|
||||
public string TokenHash { get; set; } = string.Empty;
|
||||
|
||||
public Guid UserId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Денормализованный логин пользователя — для чтения /me без join.
|
||||
/// </summary>
|
||||
public string Login { get; set; } = string.Empty;
|
||||
|
||||
public DateTimeOffset ExpiresAt { get; set; }
|
||||
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Маркер impersonation
|
||||
/// </summary>
|
||||
public Guid? ImpersonatedByOperatorId { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace Deal.Infrastructure.Persistence.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Тенант в системной схеме public.
|
||||
/// </summary>
|
||||
public sealed class TenantEntity
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
public string Status { get; set; } = "active";
|
||||
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
namespace Deal.Infrastructure.Persistence.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Лимит ИИ-бюджета тенанта в системной схеме public.
|
||||
/// </summary>
|
||||
public sealed class TenantLimitEntity
|
||||
{
|
||||
/// <summary>
|
||||
/// Тенант, которому принадлежит лимит
|
||||
/// </summary>
|
||||
public Guid TenantId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Бюджет периода в токенах.
|
||||
/// </summary>
|
||||
public long BudgetTokens { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Тип периода: month|day.
|
||||
/// </summary>
|
||||
public string Period { get; set; } = "month";
|
||||
|
||||
/// <summary>
|
||||
/// Начало текущего периода
|
||||
/// </summary>
|
||||
public DateTimeOffset PeriodStart { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Использовано токенов с начала периода.
|
||||
/// </summary>
|
||||
public long UsedTokens { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Флаг: тост о расходе 80% бюджета уже отправлен
|
||||
/// </summary>
|
||||
public bool Warned80 { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Флаг: тост об исчерпании бюджета уже отправлен
|
||||
/// </summary>
|
||||
public bool NotifiedExhausted { get; set; }
|
||||
|
||||
public DateTimeOffset UpdatedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
namespace Deal.Infrastructure.Persistence.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Настройка тенанта
|
||||
/// </summary>
|
||||
public sealed class TenantSettingEntity
|
||||
{
|
||||
public string Key { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Значение настройки, сериализованное в JSON.
|
||||
/// </summary>
|
||||
public string ValueJson { get; set; } = string.Empty;
|
||||
|
||||
public DateTimeOffset UpdatedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
namespace Deal.Infrastructure.Persistence.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Строка превью сообщения диалога
|
||||
/// </summary>
|
||||
public sealed class TgMessageEntity
|
||||
{
|
||||
/// <summary>
|
||||
/// Id строки превью
|
||||
/// </summary>
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Id диалога-источника.
|
||||
/// </summary>
|
||||
public string DialogId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Текст сообщения.
|
||||
/// </summary>
|
||||
public string Text { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Время сообщения.
|
||||
/// </summary>
|
||||
public DateTimeOffset MsgAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Id карточки, созданной по сообщению
|
||||
/// </summary>
|
||||
public string? LeadId { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
namespace Deal.Infrastructure.Persistence.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Событие расхода токенов
|
||||
/// </summary>
|
||||
public sealed class TokenUsageEventEntity
|
||||
{
|
||||
public long Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Тенант события
|
||||
/// </summary>
|
||||
public Guid TenantId { get; set; }
|
||||
|
||||
public DateTimeOffset At { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Провайдер/источник
|
||||
/// </summary>
|
||||
public string Provider { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Модель провайдера.
|
||||
/// </summary>
|
||||
public string Model { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Вид вызова: ai|ml
|
||||
/// </summary>
|
||||
public string Kind { get; set; } = string.Empty;
|
||||
|
||||
public long PromptTokens { get; set; }
|
||||
|
||||
public long CompletionTokens { get; set; }
|
||||
|
||||
public long TotalTokens { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Детали события в JSON
|
||||
/// </summary>
|
||||
public string? DetailJson { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace Deal.Infrastructure.Persistence.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Пользователь тенанта в системной схеме public.
|
||||
/// </summary>
|
||||
public sealed class UserEntity
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
|
||||
/// <summary>
|
||||
/// Логин пользователя
|
||||
/// </summary>
|
||||
public string Login { get; set; } = string.Empty;
|
||||
|
||||
public Guid TenantId { get; set; }
|
||||
|
||||
public string PasswordHash { get; set; } = string.Empty;
|
||||
|
||||
public string Status { get; set; } = "active";
|
||||
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
}
|
||||
+744
@@ -0,0 +1,744 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Deal.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
using NpgsqlTypes;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Deal.Infrastructure.Persistence.Migrations.TenantDb
|
||||
{
|
||||
[DbContext(typeof(TenantDbContext))]
|
||||
[Migration("20260911114400_InitialTenant")]
|
||||
partial class InitialTenant
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "10.0.11")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.CardEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset?>("ArchivedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("BudgetCur")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<double?>("BudgetFrom")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.Property<double?>("BudgetTo")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.Property<string>("Col")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<string>("Contact")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ContactsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ContentJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ConvCur")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<double?>("ConvFrom")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.Property<double?>("ConvTo")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("FilesJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("HistoryJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("IsNew")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("IsVacancy")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("IsVacancyKnown")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("LinksJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("Local")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("MatchHitsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("PrevCol")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("ReceivedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTimeOffset?>("ReminderAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<bool>("ReminderFired")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<NpgsqlTsVector>("SearchTsv")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAddOrUpdate()
|
||||
.HasColumnType("tsvector")
|
||||
.HasComputedColumnSql("to_tsvector('russian', coalesce(\"Title\",'')||' '||coalesce(\"Summary\",'')||' '||coalesce(\"SourceText\",'')||' '||coalesce(\"Contact\",''))", true);
|
||||
|
||||
b.Property<string>("SourceExternalId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("SourceJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("SourceKind")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<string>("SourceOriginRef")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("SourceText")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("StackJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Summary")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("TzText")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("SearchTsv");
|
||||
|
||||
NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("SearchTsv"), "gin");
|
||||
|
||||
b.HasIndex("UpdatedAt")
|
||||
.IsDescending();
|
||||
|
||||
b.HasIndex("Col", "IsNew");
|
||||
|
||||
b.HasIndex("Col", "ReceivedAt")
|
||||
.IsDescending(false, true);
|
||||
|
||||
b.ToTable("Cards", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.CardMoveEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Action")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("FromCol")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("LeadId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ToCol")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("CardMoves", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.ContainerEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("Collapsed")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("Color")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Kind")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Note")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("PolicyJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("Position")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("RulesJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<NpgsqlTsVector>("SearchTsv")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAddOrUpdate()
|
||||
.HasColumnType("tsvector")
|
||||
.HasComputedColumnSql("to_tsvector('russian', coalesce(\"Name\",'')||' '||coalesce(\"Description\",''))", true);
|
||||
|
||||
b.Property<string>("Space")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)");
|
||||
|
||||
b.Property<bool>("Suggested")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("SearchTsv");
|
||||
|
||||
NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("SearchTsv"), "gin");
|
||||
|
||||
b.HasIndex("Space", "Position");
|
||||
|
||||
b.HasIndex("Suggested", "Position");
|
||||
|
||||
b.ToTable("Containers", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.DedupEntryEntity", b =>
|
||||
{
|
||||
b.Property<string>("Hash")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("LeadId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Hash");
|
||||
|
||||
b.ToTable("DedupEntries", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.DialogEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("Backfilled")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("Handle")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Hue")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("text")
|
||||
.HasDefaultValue("#666");
|
||||
|
||||
b.Property<string>("Kind")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset?>("LastAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("LastText")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("Monitor")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Dialogs", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.DiscBlacklistEntity", b =>
|
||||
{
|
||||
b.Property<string>("DialogId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Reason")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("DialogId");
|
||||
|
||||
b.ToTable("DiscBlacklist", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.DiscCandidateEntity", b =>
|
||||
{
|
||||
b.Property<string>("DialogId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("AutoJoined")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<double?>("FitRatio")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.Property<string>("Hue")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("JoinFailures")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Kind")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool?>("LangRu")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("MarksJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int?>("Participants")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("TaskId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("TopicsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Username")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("DialogId");
|
||||
|
||||
b.HasIndex("TaskId", "Status");
|
||||
|
||||
b.ToTable("DiscCandidates", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.DiscLogEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Event")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("TaskId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Text")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TaskId", "CreatedAt");
|
||||
|
||||
b.ToTable("DiscLog", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.DiscTaskEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("AutoJoin")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("Evaluated")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("Found")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("Joined")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("KeywordsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Lang")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("MinSubscribers")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("PlanJoins")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("Rejected")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("SampleSize")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<bool>("SearchDone")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<int>("SearchIdx")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("Threshold")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("DiscTasks", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.LeadCommentEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("By")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("CardId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Text")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CardId");
|
||||
|
||||
b.ToTable("LeadComments", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.MlOutboxEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<double>("Delta")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.Property<string>("Label")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Text")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CreatedAt");
|
||||
|
||||
b.ToTable("MlOutbox", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.QueueItemEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ContentJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<bool>("Force")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<DateTimeOffset>("MsgAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("SourceJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("SourceKey")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Text")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("SourceKey");
|
||||
|
||||
b.HasIndex("Status", "CreatedAt");
|
||||
|
||||
b.ToTable("QueueItems", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.RejectedItemEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ContentJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Kw")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("MsgAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Reason")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("RejectedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("ReturnReason")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("Returned")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<DateTimeOffset?>("ReturnedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<NpgsqlTsVector>("SearchTsv")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAddOrUpdate()
|
||||
.HasColumnType("tsvector")
|
||||
.HasComputedColumnSql("to_tsvector('russian', coalesce(\"Text\",''))", true);
|
||||
|
||||
b.Property<string>("Source")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("SourceJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("SourceKey")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Stage")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Text")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("RejectedAt");
|
||||
|
||||
b.HasIndex("SearchTsv");
|
||||
|
||||
NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("SearchTsv"), "gin");
|
||||
|
||||
b.ToTable("RejectedItems", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.TenantSettingEntity", b =>
|
||||
{
|
||||
b.Property<string>("Key")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("ValueJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Key");
|
||||
|
||||
b.ToTable("settings", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.TgMessageEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("DialogId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("LeadId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("MsgAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Text")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("DialogId", "MsgAt");
|
||||
|
||||
b.ToTable("TgMessages", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.LeadCommentEntity", b =>
|
||||
{
|
||||
b.HasOne("Deal.Infrastructure.Persistence.Entities.CardEntity", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("CardId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
+462
@@ -0,0 +1,462 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using NpgsqlTypes;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Deal.Infrastructure.Persistence.Migrations.TenantDb
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class InitialTenant : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "CardMoves",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<string>(type: "text", nullable: false),
|
||||
LeadId = table.Column<string>(type: "text", nullable: false),
|
||||
Action = table.Column<string>(type: "text", nullable: false),
|
||||
FromCol = table.Column<string>(type: "text", nullable: true),
|
||||
ToCol = table.Column<string>(type: "text", nullable: true),
|
||||
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_CardMoves", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Cards",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<string>(type: "text", nullable: false),
|
||||
Col = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
|
||||
IsNew = table.Column<bool>(type: "boolean", nullable: false),
|
||||
Local = table.Column<bool>(type: "boolean", nullable: false),
|
||||
IsVacancy = table.Column<bool>(type: "boolean", nullable: false),
|
||||
IsVacancyKnown = table.Column<bool>(type: "boolean", nullable: false),
|
||||
Title = table.Column<string>(type: "text", nullable: false),
|
||||
Summary = table.Column<string>(type: "text", nullable: false),
|
||||
StackJson = table.Column<string>(type: "text", nullable: false),
|
||||
BudgetFrom = table.Column<double>(type: "double precision", nullable: true),
|
||||
BudgetTo = table.Column<double>(type: "double precision", nullable: true),
|
||||
BudgetCur = table.Column<string>(type: "text", nullable: false),
|
||||
ConvFrom = table.Column<double>(type: "double precision", nullable: true),
|
||||
ConvTo = table.Column<double>(type: "double precision", nullable: true),
|
||||
ConvCur = table.Column<string>(type: "text", nullable: false),
|
||||
Contact = table.Column<string>(type: "text", nullable: false),
|
||||
ContactsJson = table.Column<string>(type: "text", nullable: false),
|
||||
ReceivedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
|
||||
SourceKind = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
|
||||
SourceExternalId = table.Column<string>(type: "text", nullable: false),
|
||||
SourceOriginRef = table.Column<string>(type: "text", nullable: false),
|
||||
SourceJson = table.Column<string>(type: "text", nullable: false),
|
||||
ContentJson = table.Column<string>(type: "text", nullable: false),
|
||||
SourceText = table.Column<string>(type: "text", nullable: false),
|
||||
PrevCol = table.Column<string>(type: "text", nullable: false),
|
||||
ArchivedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
|
||||
MatchHitsJson = table.Column<string>(type: "text", nullable: false),
|
||||
LinksJson = table.Column<string>(type: "text", nullable: false),
|
||||
FilesJson = table.Column<string>(type: "text", nullable: false),
|
||||
HistoryJson = table.Column<string>(type: "text", nullable: false),
|
||||
TzText = table.Column<string>(type: "text", nullable: false),
|
||||
ReminderAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
|
||||
ReminderFired = table.Column<bool>(type: "boolean", nullable: false),
|
||||
SearchTsv = table.Column<NpgsqlTsVector>(type: "tsvector", nullable: false, computedColumnSql: "to_tsvector('russian', coalesce(\"Title\",'')||' '||coalesce(\"Summary\",'')||' '||coalesce(\"SourceText\",'')||' '||coalesce(\"Contact\",''))", stored: true),
|
||||
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
|
||||
UpdatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Cards", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Containers",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<string>(type: "text", nullable: false),
|
||||
Name = table.Column<string>(type: "text", nullable: false),
|
||||
Description = table.Column<string>(type: "text", nullable: false),
|
||||
Color = table.Column<string>(type: "text", nullable: false),
|
||||
Position = table.Column<int>(type: "integer", nullable: false),
|
||||
Kind = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
|
||||
Space = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
|
||||
Collapsed = table.Column<bool>(type: "boolean", nullable: false),
|
||||
Suggested = table.Column<bool>(type: "boolean", nullable: false),
|
||||
RulesJson = table.Column<string>(type: "text", nullable: false),
|
||||
Note = table.Column<string>(type: "text", nullable: false),
|
||||
PolicyJson = table.Column<string>(type: "text", nullable: false),
|
||||
SearchTsv = table.Column<NpgsqlTsVector>(type: "tsvector", nullable: false, computedColumnSql: "to_tsvector('russian', coalesce(\"Name\",'')||' '||coalesce(\"Description\",''))", stored: true),
|
||||
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Containers", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "DedupEntries",
|
||||
columns: table => new
|
||||
{
|
||||
Hash = table.Column<string>(type: "text", nullable: false),
|
||||
LeadId = table.Column<string>(type: "text", nullable: true),
|
||||
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_DedupEntries", x => x.Hash);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Dialogs",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<string>(type: "text", nullable: false),
|
||||
Name = table.Column<string>(type: "text", nullable: false),
|
||||
Handle = table.Column<string>(type: "text", nullable: false),
|
||||
Kind = table.Column<string>(type: "text", nullable: false),
|
||||
Hue = table.Column<string>(type: "text", nullable: false, defaultValue: "#666"),
|
||||
Monitor = table.Column<bool>(type: "boolean", nullable: false),
|
||||
LastText = table.Column<string>(type: "text", nullable: false),
|
||||
LastAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
|
||||
Backfilled = table.Column<bool>(type: "boolean", nullable: false),
|
||||
UpdatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Dialogs", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "DiscBlacklist",
|
||||
columns: table => new
|
||||
{
|
||||
DialogId = table.Column<string>(type: "text", nullable: false),
|
||||
Name = table.Column<string>(type: "text", nullable: false),
|
||||
Reason = table.Column<string>(type: "text", nullable: false),
|
||||
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_DiscBlacklist", x => x.DialogId);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "DiscCandidates",
|
||||
columns: table => new
|
||||
{
|
||||
DialogId = table.Column<string>(type: "text", nullable: false),
|
||||
TaskId = table.Column<string>(type: "text", nullable: false),
|
||||
Name = table.Column<string>(type: "text", nullable: false),
|
||||
Username = table.Column<string>(type: "text", nullable: false),
|
||||
Kind = table.Column<string>(type: "text", nullable: false),
|
||||
Hue = table.Column<string>(type: "text", nullable: false),
|
||||
Participants = table.Column<int>(type: "integer", nullable: true),
|
||||
LangRu = table.Column<bool>(type: "boolean", nullable: true),
|
||||
MarksJson = table.Column<string>(type: "text", nullable: false),
|
||||
TopicsJson = table.Column<string>(type: "text", nullable: false),
|
||||
FitRatio = table.Column<double>(type: "double precision", nullable: true),
|
||||
Status = table.Column<string>(type: "text", nullable: false),
|
||||
AutoJoined = table.Column<bool>(type: "boolean", nullable: false),
|
||||
JoinFailures = table.Column<int>(type: "integer", nullable: false),
|
||||
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
|
||||
UpdatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_DiscCandidates", x => x.DialogId);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "DiscLog",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<string>(type: "text", nullable: false),
|
||||
TaskId = table.Column<string>(type: "text", nullable: false),
|
||||
Event = table.Column<string>(type: "text", nullable: false),
|
||||
Text = table.Column<string>(type: "text", nullable: false),
|
||||
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_DiscLog", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "DiscTasks",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<string>(type: "text", nullable: false),
|
||||
Name = table.Column<string>(type: "text", nullable: false),
|
||||
Description = table.Column<string>(type: "text", nullable: false),
|
||||
KeywordsJson = table.Column<string>(type: "text", nullable: false),
|
||||
MinSubscribers = table.Column<int>(type: "integer", nullable: false),
|
||||
Lang = table.Column<string>(type: "text", nullable: false),
|
||||
Threshold = table.Column<int>(type: "integer", nullable: false),
|
||||
SampleSize = table.Column<int>(type: "integer", nullable: false),
|
||||
PlanJoins = table.Column<int>(type: "integer", nullable: false),
|
||||
AutoJoin = table.Column<bool>(type: "boolean", nullable: false),
|
||||
Status = table.Column<string>(type: "text", nullable: false),
|
||||
SearchIdx = table.Column<int>(type: "integer", nullable: false),
|
||||
SearchDone = table.Column<bool>(type: "boolean", nullable: false),
|
||||
Found = table.Column<int>(type: "integer", nullable: false),
|
||||
Evaluated = table.Column<int>(type: "integer", nullable: false),
|
||||
Joined = table.Column<int>(type: "integer", nullable: false),
|
||||
Rejected = table.Column<int>(type: "integer", nullable: false),
|
||||
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
|
||||
UpdatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_DiscTasks", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "MlOutbox",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<string>(type: "text", nullable: false),
|
||||
Text = table.Column<string>(type: "text", nullable: false),
|
||||
Label = table.Column<string>(type: "text", nullable: false),
|
||||
Delta = table.Column<double>(type: "double precision", nullable: false),
|
||||
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_MlOutbox", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "QueueItems",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<string>(type: "text", nullable: false),
|
||||
SourceKey = table.Column<string>(type: "text", nullable: false),
|
||||
SourceJson = table.Column<string>(type: "text", nullable: false),
|
||||
ContentJson = table.Column<string>(type: "text", nullable: false),
|
||||
Text = table.Column<string>(type: "text", nullable: false),
|
||||
MsgAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
|
||||
Status = table.Column<string>(type: "text", nullable: false),
|
||||
Force = table.Column<bool>(type: "boolean", nullable: false),
|
||||
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
|
||||
UpdatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_QueueItems", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "RejectedItems",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<string>(type: "text", nullable: false),
|
||||
SourceKey = table.Column<string>(type: "text", nullable: false),
|
||||
SourceJson = table.Column<string>(type: "text", nullable: false),
|
||||
ContentJson = table.Column<string>(type: "text", nullable: false),
|
||||
Text = table.Column<string>(type: "text", nullable: false),
|
||||
Stage = table.Column<string>(type: "text", nullable: false),
|
||||
Reason = table.Column<string>(type: "text", nullable: false),
|
||||
Kw = table.Column<string>(type: "text", nullable: false),
|
||||
Source = table.Column<string>(type: "text", nullable: false),
|
||||
MsgAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
|
||||
RejectedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
|
||||
Returned = table.Column<bool>(type: "boolean", nullable: false),
|
||||
ReturnedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
|
||||
ReturnReason = table.Column<string>(type: "text", nullable: false),
|
||||
SearchTsv = table.Column<NpgsqlTsVector>(type: "tsvector", nullable: false, computedColumnSql: "to_tsvector('russian', coalesce(\"Text\",''))", stored: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_RejectedItems", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "settings",
|
||||
columns: table => new
|
||||
{
|
||||
Key = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
|
||||
ValueJson = table.Column<string>(type: "text", nullable: false),
|
||||
UpdatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_settings", x => x.Key);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "TgMessages",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<string>(type: "text", nullable: false),
|
||||
DialogId = table.Column<string>(type: "text", nullable: false),
|
||||
Text = table.Column<string>(type: "text", nullable: false),
|
||||
MsgAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
|
||||
LeadId = table.Column<string>(type: "text", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_TgMessages", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "LeadComments",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<string>(type: "text", nullable: false),
|
||||
CardId = table.Column<string>(type: "text", nullable: false),
|
||||
By = table.Column<string>(type: "text", nullable: false),
|
||||
Text = table.Column<string>(type: "text", nullable: false),
|
||||
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_LeadComments", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_LeadComments_Cards_CardId",
|
||||
column: x => x.CardId,
|
||||
principalTable: "Cards",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Cards_Col_IsNew",
|
||||
table: "Cards",
|
||||
columns: new[] { "Col", "IsNew" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Cards_Col_ReceivedAt",
|
||||
table: "Cards",
|
||||
columns: new[] { "Col", "ReceivedAt" },
|
||||
descending: new[] { false, true });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Cards_SearchTsv",
|
||||
table: "Cards",
|
||||
column: "SearchTsv")
|
||||
.Annotation("Npgsql:IndexMethod", "gin");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Cards_UpdatedAt",
|
||||
table: "Cards",
|
||||
column: "UpdatedAt",
|
||||
descending: new bool[0]);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Containers_SearchTsv",
|
||||
table: "Containers",
|
||||
column: "SearchTsv")
|
||||
.Annotation("Npgsql:IndexMethod", "gin");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Containers_Space_Position",
|
||||
table: "Containers",
|
||||
columns: new[] { "Space", "Position" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Containers_Suggested_Position",
|
||||
table: "Containers",
|
||||
columns: new[] { "Suggested", "Position" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_DiscCandidates_TaskId_Status",
|
||||
table: "DiscCandidates",
|
||||
columns: new[] { "TaskId", "Status" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_DiscLog_TaskId_CreatedAt",
|
||||
table: "DiscLog",
|
||||
columns: new[] { "TaskId", "CreatedAt" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_LeadComments_CardId",
|
||||
table: "LeadComments",
|
||||
column: "CardId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_MlOutbox_CreatedAt",
|
||||
table: "MlOutbox",
|
||||
column: "CreatedAt");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_QueueItems_SourceKey",
|
||||
table: "QueueItems",
|
||||
column: "SourceKey");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_QueueItems_Status_CreatedAt",
|
||||
table: "QueueItems",
|
||||
columns: new[] { "Status", "CreatedAt" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_RejectedItems_RejectedAt",
|
||||
table: "RejectedItems",
|
||||
column: "RejectedAt");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_RejectedItems_SearchTsv",
|
||||
table: "RejectedItems",
|
||||
column: "SearchTsv")
|
||||
.Annotation("Npgsql:IndexMethod", "gin");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_TgMessages_DialogId_MsgAt",
|
||||
table: "TgMessages",
|
||||
columns: new[] { "DialogId", "MsgAt" });
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "CardMoves");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Containers");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "DedupEntries");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Dialogs");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "DiscBlacklist");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "DiscCandidates");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "DiscLog");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "DiscTasks");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "LeadComments");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "MlOutbox");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "QueueItems");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "RejectedItems");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "settings");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "TgMessages");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Cards");
|
||||
}
|
||||
}
|
||||
}
|
||||
+741
@@ -0,0 +1,741 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Deal.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
using NpgsqlTypes;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Deal.Infrastructure.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<string>("Id")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset?>("ArchivedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("BudgetCur")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<double?>("BudgetFrom")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.Property<double?>("BudgetTo")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.Property<string>("Col")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<string>("Contact")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ContactsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ContentJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ConvCur")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<double?>("ConvFrom")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.Property<double?>("ConvTo")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("FilesJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("HistoryJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("IsNew")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("IsVacancy")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("IsVacancyKnown")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("LinksJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("Local")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("MatchHitsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("PrevCol")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("ReceivedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTimeOffset?>("ReminderAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<bool>("ReminderFired")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<NpgsqlTsVector>("SearchTsv")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAddOrUpdate()
|
||||
.HasColumnType("tsvector")
|
||||
.HasComputedColumnSql("to_tsvector('russian', coalesce(\"Title\",'')||' '||coalesce(\"Summary\",'')||' '||coalesce(\"SourceText\",'')||' '||coalesce(\"Contact\",''))", true);
|
||||
|
||||
b.Property<string>("SourceExternalId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("SourceJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("SourceKind")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<string>("SourceOriginRef")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("SourceText")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("StackJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Summary")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("TzText")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("SearchTsv");
|
||||
|
||||
NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("SearchTsv"), "gin");
|
||||
|
||||
b.HasIndex("UpdatedAt")
|
||||
.IsDescending();
|
||||
|
||||
b.HasIndex("Col", "IsNew");
|
||||
|
||||
b.HasIndex("Col", "ReceivedAt")
|
||||
.IsDescending(false, true);
|
||||
|
||||
b.ToTable("Cards", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.CardMoveEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Action")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("FromCol")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("LeadId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ToCol")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("CardMoves", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.ContainerEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("Collapsed")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("Color")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Kind")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Note")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("PolicyJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("Position")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("RulesJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<NpgsqlTsVector>("SearchTsv")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAddOrUpdate()
|
||||
.HasColumnType("tsvector")
|
||||
.HasComputedColumnSql("to_tsvector('russian', coalesce(\"Name\",'')||' '||coalesce(\"Description\",''))", true);
|
||||
|
||||
b.Property<string>("Space")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)");
|
||||
|
||||
b.Property<bool>("Suggested")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("SearchTsv");
|
||||
|
||||
NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("SearchTsv"), "gin");
|
||||
|
||||
b.HasIndex("Space", "Position");
|
||||
|
||||
b.HasIndex("Suggested", "Position");
|
||||
|
||||
b.ToTable("Containers", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.DedupEntryEntity", b =>
|
||||
{
|
||||
b.Property<string>("Hash")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("LeadId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Hash");
|
||||
|
||||
b.ToTable("DedupEntries", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.DialogEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("Backfilled")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("Handle")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Hue")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("text")
|
||||
.HasDefaultValue("#666");
|
||||
|
||||
b.Property<string>("Kind")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset?>("LastAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("LastText")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("Monitor")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Dialogs", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.DiscBlacklistEntity", b =>
|
||||
{
|
||||
b.Property<string>("DialogId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Reason")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("DialogId");
|
||||
|
||||
b.ToTable("DiscBlacklist", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.DiscCandidateEntity", b =>
|
||||
{
|
||||
b.Property<string>("DialogId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("AutoJoined")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<double?>("FitRatio")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.Property<string>("Hue")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("JoinFailures")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Kind")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool?>("LangRu")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("MarksJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int?>("Participants")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("TaskId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("TopicsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Username")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("DialogId");
|
||||
|
||||
b.HasIndex("TaskId", "Status");
|
||||
|
||||
b.ToTable("DiscCandidates", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.DiscLogEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Event")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("TaskId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Text")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TaskId", "CreatedAt");
|
||||
|
||||
b.ToTable("DiscLog", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.DiscTaskEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("AutoJoin")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("Evaluated")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("Found")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("Joined")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("KeywordsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Lang")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("MinSubscribers")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("PlanJoins")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("Rejected")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("SampleSize")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<bool>("SearchDone")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<int>("SearchIdx")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("Threshold")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("DiscTasks", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.LeadCommentEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("By")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("CardId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Text")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CardId");
|
||||
|
||||
b.ToTable("LeadComments", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.MlOutboxEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<double>("Delta")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.Property<string>("Label")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Text")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CreatedAt");
|
||||
|
||||
b.ToTable("MlOutbox", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.QueueItemEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ContentJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<bool>("Force")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<DateTimeOffset>("MsgAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("SourceJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("SourceKey")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Text")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("SourceKey");
|
||||
|
||||
b.HasIndex("Status", "CreatedAt");
|
||||
|
||||
b.ToTable("QueueItems", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.RejectedItemEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ContentJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Kw")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("MsgAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Reason")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("RejectedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("ReturnReason")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("Returned")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<DateTimeOffset?>("ReturnedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<NpgsqlTsVector>("SearchTsv")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAddOrUpdate()
|
||||
.HasColumnType("tsvector")
|
||||
.HasComputedColumnSql("to_tsvector('russian', coalesce(\"Text\",''))", true);
|
||||
|
||||
b.Property<string>("Source")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("SourceJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("SourceKey")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Stage")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Text")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("RejectedAt");
|
||||
|
||||
b.HasIndex("SearchTsv");
|
||||
|
||||
NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("SearchTsv"), "gin");
|
||||
|
||||
b.ToTable("RejectedItems", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.TenantSettingEntity", b =>
|
||||
{
|
||||
b.Property<string>("Key")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("ValueJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Key");
|
||||
|
||||
b.ToTable("settings", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.TgMessageEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("DialogId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("LeadId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("MsgAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Text")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("DialogId", "MsgAt");
|
||||
|
||||
b.ToTable("TgMessages", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Deal.Infrastructure.Persistence.Entities.LeadCommentEntity", b =>
|
||||
{
|
||||
b.HasOne("Deal.Infrastructure.Persistence.Entities.CardEntity", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("CardId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
using Deal.Infrastructure.Persistence.Entities;
|
||||
using Deal.Modules.Tenants.Application.Abstractions;
|
||||
using Deal.Modules.Tenants.Application.Models;
|
||||
using Deal.Modules.Tenants.Application.Services;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Deal.Infrastructure.Persistence.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// EF-адаптер хранилища аудита
|
||||
/// </summary>
|
||||
public sealed class AuditLogStore(DealDbContext dbContext) : IAuditLogStore
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task AppendAsync(AuditRecordDto record, CancellationToken ct)
|
||||
{
|
||||
dbContext.AuditLog.Add(new AuditLogEntity
|
||||
{
|
||||
// Id генерирует БД (identity) — из DTO не копируется.
|
||||
At = record.At,
|
||||
ActorType = record.ActorType,
|
||||
ActorId = record.ActorId,
|
||||
TenantId = record.TenantId,
|
||||
EventType = record.EventType,
|
||||
Ip = record.Ip,
|
||||
DetailJson = record.DetailJson,
|
||||
});
|
||||
await dbContext.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<AuditRecordDto>> QueryAsync(AuditQueryDto filter, CancellationToken ct)
|
||||
{
|
||||
var entities = await ApplyFilters(dbContext.AuditLog.AsNoTracking(), filter)
|
||||
.OrderByDescending(a => a.At)
|
||||
.Skip(Math.Max(0, filter.Offset))
|
||||
.Take(ClampLimit(filter.Limit))
|
||||
.ToListAsync(ct);
|
||||
return entities.Select(ToAuditRecordDto).ToList();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<int> CountAsync(AuditQueryDto filter, CancellationToken ct)
|
||||
{
|
||||
return await ApplyFilters(dbContext.AuditLog.AsNoTracking(), filter).CountAsync(ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<int> PurgeOlderThanAsync(DateTimeOffset cutoff, CancellationToken ct)
|
||||
{
|
||||
if (dbContext.Database.IsNpgsql())
|
||||
{
|
||||
// Операционная авто-очистка: удаляем только устаревшие строки одним DELETE.
|
||||
return await dbContext.AuditLog.Where(a => a.At < cutoff).ExecuteDeleteAsync(ct);
|
||||
}
|
||||
|
||||
// InMemory-провайдер (тесты) ExecuteDelete не исполняет — выгрузка и RemoveRange.
|
||||
List<AuditLogEntity> expired = await dbContext.AuditLog
|
||||
.Where(a => a.At < cutoff)
|
||||
.ToListAsync(ct);
|
||||
if (expired.Count == 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
dbContext.AuditLog.RemoveRange(expired);
|
||||
await dbContext.SaveChangesAsync(ct);
|
||||
return expired.Count;
|
||||
}
|
||||
|
||||
// Применяет фильтры выборки (EventType/ActorType/TenantId/ActorId/At-range); без Limit/Offset/сортировки.
|
||||
// query: Базовый запрос.
|
||||
// filter: Фильтр выборки.
|
||||
// Возвращает: Запрос с фильтрами.
|
||||
private static IQueryable<AuditLogEntity> ApplyFilters(IQueryable<AuditLogEntity> query, AuditQueryDto filter)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(filter.EventType))
|
||||
{
|
||||
query = query.Where(a => a.EventType == filter.EventType);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(filter.ActorType))
|
||||
{
|
||||
query = query.Where(a => a.ActorType == filter.ActorType);
|
||||
}
|
||||
|
||||
if (filter.TenantId is not null)
|
||||
{
|
||||
query = query.Where(a => a.TenantId == filter.TenantId);
|
||||
}
|
||||
|
||||
if (filter.ActorId is not null)
|
||||
{
|
||||
query = query.Where(a => a.ActorId == filter.ActorId);
|
||||
}
|
||||
|
||||
if (filter.From is not null)
|
||||
{
|
||||
query = query.Where(a => a.At >= filter.From.Value);
|
||||
}
|
||||
|
||||
if (filter.To is not null)
|
||||
{
|
||||
query = query.Where(a => a.At <= filter.To.Value);
|
||||
}
|
||||
|
||||
return query;
|
||||
}
|
||||
|
||||
private static int ClampLimit(int limit) => Math.Max(1, Math.Min(AuditService.MaxQueryLimit, limit));
|
||||
|
||||
private static AuditRecordDto ToAuditRecordDto(AuditLogEntity entity) =>
|
||||
new(
|
||||
entity.EventType,
|
||||
entity.ActorType,
|
||||
entity.ActorId,
|
||||
entity.TenantId,
|
||||
entity.Ip,
|
||||
entity.DetailJson,
|
||||
entity.At,
|
||||
entity.Id);
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
using Deal.Infrastructure.Persistence.Entities;
|
||||
using Deal.Modules.Tenants.Application.Abstractions;
|
||||
using Deal.Modules.Tenants.Application.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Deal.Infrastructure.Persistence.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// EF-адаптер хранилища аутентификации
|
||||
/// </summary>
|
||||
public sealed class AuthStore(DealDbContext dbContext) : IAuthStore
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<StoredUserDto?> FindUserByLoginAsync(string login, CancellationToken ct)
|
||||
{
|
||||
var entity = await dbContext.Users
|
||||
.AsNoTracking()
|
||||
.SingleOrDefaultAsync(u => u.Login == login, ct);
|
||||
return entity is null ? null : ToStoredUserDto(entity);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task CreateUserAsync(StoredUserDto user, CancellationToken ct)
|
||||
{
|
||||
dbContext.Users.Add(new UserEntity
|
||||
{
|
||||
Id = user.Id,
|
||||
Login = user.Login,
|
||||
TenantId = user.TenantId,
|
||||
Status = user.Status,
|
||||
PasswordHash = user.PasswordHash,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
});
|
||||
await dbContext.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<SessionDto?> FindSessionByTokenHashAsync(string tokenHash, CancellationToken ct)
|
||||
{
|
||||
// Протухшие сессии не возвращаем: с момента истечения они «не существуют»
|
||||
// (физическую очистку выполняет DeleteExpiredSessionsAsync).
|
||||
var entity = await dbContext.Sessions
|
||||
.AsNoTracking()
|
||||
.SingleOrDefaultAsync(s => s.TokenHash == tokenHash && s.ExpiresAt > DateTimeOffset.UtcNow, ct);
|
||||
return entity is null ? null : ToSessionDto(entity);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<UserIdentityDto?> FindUserByIdAsync(Guid userId, CancellationToken ct)
|
||||
{
|
||||
var entity = await dbContext.Users
|
||||
.AsNoTracking()
|
||||
.SingleOrDefaultAsync(u => u.Id == userId, ct);
|
||||
return entity is null ? null : ToUserIdentityDto(entity);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<UserIdentityDto>> ListUsersByTenantIdAsync(Guid tenantId, CancellationToken ct)
|
||||
{
|
||||
var entities = await dbContext.Users
|
||||
.AsNoTracking()
|
||||
.Where(u => u.TenantId == tenantId)
|
||||
.OrderBy(u => u.CreatedAt)
|
||||
.ThenBy(u => u.Login)
|
||||
.Select(u => ToUserIdentityDto(u))
|
||||
.ToListAsync(ct);
|
||||
return entities;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task CreateSessionAsync(SessionDto session, CancellationToken ct)
|
||||
{
|
||||
dbContext.Sessions.Add(new SessionEntity
|
||||
{
|
||||
TokenHash = session.TokenHash,
|
||||
UserId = session.UserId,
|
||||
Login = session.Login,
|
||||
ExpiresAt = session.ExpiresAt,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
ImpersonatedByOperatorId = session.ImpersonatedByOperatorId,
|
||||
});
|
||||
await dbContext.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task DeleteSessionAsync(string tokenHash, CancellationToken ct)
|
||||
{
|
||||
await dbContext.Sessions
|
||||
.Where(s => s.TokenHash == tokenHash)
|
||||
.ExecuteDeleteAsync(ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task DeleteSessionsByUserIdAsync(Guid userId, CancellationToken ct)
|
||||
{
|
||||
await dbContext.Sessions
|
||||
.Where(s => s.UserId == userId)
|
||||
.ExecuteDeleteAsync(ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task UpdatePasswordHashAsync(
|
||||
Guid userId,
|
||||
string passwordHash,
|
||||
CancellationToken ct)
|
||||
{
|
||||
await dbContext.Users
|
||||
.Where(u => u.Id == userId)
|
||||
.ExecuteUpdateAsync(s => s.SetProperty(u => u.PasswordHash, passwordHash), ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task DeleteExpiredSessionsAsync(CancellationToken ct)
|
||||
{
|
||||
await dbContext.Sessions
|
||||
.Where(s => s.ExpiresAt <= DateTimeOffset.UtcNow)
|
||||
.ExecuteDeleteAsync(ct);
|
||||
}
|
||||
|
||||
private static StoredUserDto ToStoredUserDto(UserEntity entity) =>
|
||||
new(entity.Id, entity.Login, entity.TenantId, entity.Status, entity.PasswordHash);
|
||||
|
||||
private static UserIdentityDto ToUserIdentityDto(UserEntity entity) =>
|
||||
new(entity.Id, entity.Login, entity.TenantId, entity.Status);
|
||||
|
||||
private static SessionDto ToSessionDto(SessionEntity entity) =>
|
||||
new(entity.TokenHash, entity.UserId, entity.Login, entity.ExpiresAt, entity.ImpersonatedByOperatorId);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
using Deal.Infrastructure.Persistence.Entities;
|
||||
using Deal.Modules.Discovery.Application.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Deal.Modules.Discovery.Application.Abstractions;
|
||||
|
||||
namespace Deal.Infrastructure.Persistence.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// Чёрный список Discovery — partial-часть <see cref="DiscoveryStore"/>
|
||||
/// </summary>
|
||||
public sealed partial class DiscoveryStore
|
||||
{
|
||||
/// <inheritdoc />
|
||||
async Task IDiscoveryStore.UpsertBlacklistAsync(
|
||||
string dialogId,
|
||||
string name,
|
||||
string reason,
|
||||
CancellationToken ct)
|
||||
{
|
||||
DiscBlacklistEntity? row = await _dbContext.DiscBlacklist
|
||||
.FirstOrDefaultAsync(entry => entry.DialogId == dialogId, ct);
|
||||
if (row is null)
|
||||
{
|
||||
_dbContext.DiscBlacklist.Add(new DiscBlacklistEntity
|
||||
{
|
||||
DialogId = dialogId,
|
||||
Name = name,
|
||||
Reason = reason,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
row.Name = name;
|
||||
row.Reason = reason;
|
||||
}
|
||||
|
||||
await _dbContext.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
async Task IDiscoveryStore.RemoveBlacklistAsync(string dialogId, CancellationToken ct)
|
||||
{
|
||||
await _dbContext.DiscBlacklist.Where(entry => entry.DialogId == dialogId).ExecuteDeleteAsync(ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
async Task<DiscoveryBlacklistDto?> IDiscoveryStore.GetBlacklistAsync(string dialogId, CancellationToken ct)
|
||||
{
|
||||
DiscBlacklistEntity? row = await _dbContext.DiscBlacklist
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(entry => entry.DialogId == dialogId, ct);
|
||||
return row is null ? null : ToBlacklistDto(row);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
async Task<IReadOnlyList<DiscoveryBlacklistDto>> IDiscoveryStore.ListBlacklistAsync(CancellationToken ct)
|
||||
{
|
||||
List<DiscBlacklistEntity> rows = await _dbContext.DiscBlacklist
|
||||
.AsNoTracking()
|
||||
.OrderByDescending(entry => entry.CreatedAt)
|
||||
.ToListAsync(ct);
|
||||
return rows.Select(ToBlacklistDto).ToList();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
using Deal.Infrastructure.Persistence.Entities;
|
||||
using Deal.Modules.Discovery.Application.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Deal.Modules.Discovery.Application.Abstractions;
|
||||
|
||||
namespace Deal.Infrastructure.Persistence.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// Кандидаты Discovery — partial-часть <see cref="DiscoveryStore"/>
|
||||
/// </summary>
|
||||
public sealed partial class DiscoveryStore
|
||||
{
|
||||
/// <inheritdoc />
|
||||
async Task<IReadOnlyList<DiscoveryCandidateDto>> IDiscoveryStore.ListCandidatesAsync(
|
||||
string taskId,
|
||||
string? status,
|
||||
CancellationToken ct)
|
||||
{
|
||||
IQueryable<DiscCandidateEntity> query = _dbContext.DiscCandidates.AsNoTracking().Where(candidate => candidate.TaskId == taskId);
|
||||
if (status is not null)
|
||||
{
|
||||
query = query.Where(candidate => candidate.Status == status);
|
||||
}
|
||||
|
||||
List<DiscCandidateEntity> rows = await query.OrderBy(candidate => candidate.CreatedAt).ToListAsync(ct);
|
||||
return rows.Select(ToCandidateDto).ToList();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
async Task<DiscoveryCandidateDto?> IDiscoveryStore.GetCandidateAsync(string dialogId, CancellationToken ct)
|
||||
{
|
||||
DiscCandidateEntity? row = await _dbContext.DiscCandidates
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(candidate => candidate.DialogId == dialogId, ct);
|
||||
return row is null ? null : ToCandidateDto(row);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
async Task<bool> IDiscoveryStore.IsDialogMonitoredAsync(string dialogId, CancellationToken ct)
|
||||
{
|
||||
return await _dbContext.Dialogs.AnyAsync(dialog => dialog.Id == dialogId, ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
Task<bool> IDiscoveryStore.IsBlacklistedAsync(string dialogId, CancellationToken ct)
|
||||
{
|
||||
return _dbContext.DiscBlacklist.AnyAsync(row => row.DialogId == dialogId, ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
async Task IDiscoveryStore.CreateCandidateAsync(DiscoveryCandidateRow row, CancellationToken ct)
|
||||
{
|
||||
DateTimeOffset now = DateTimeOffset.UtcNow;
|
||||
_dbContext.DiscCandidates.Add(new DiscCandidateEntity
|
||||
{
|
||||
DialogId = row.DialogId,
|
||||
TaskId = row.TaskId,
|
||||
Name = row.Name,
|
||||
Username = row.Username,
|
||||
Kind = row.Kind,
|
||||
Hue = row.Hue,
|
||||
Status = "new",
|
||||
CreatedAt = now,
|
||||
UpdatedAt = now,
|
||||
});
|
||||
await _dbContext.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
async Task IDiscoveryStore.DeleteCandidateAsync(string dialogId, CancellationToken ct)
|
||||
{
|
||||
await _dbContext.DiscCandidates.Where(candidate => candidate.DialogId == dialogId).ExecuteDeleteAsync(ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
async Task<bool> IDiscoveryStore.PatchCandidateAsync(
|
||||
string dialogId,
|
||||
DiscoveryCandidatePatch patch,
|
||||
CancellationToken ct)
|
||||
{
|
||||
DiscCandidateEntity? row = await _dbContext.DiscCandidates
|
||||
.FirstOrDefaultAsync(candidate => candidate.DialogId == dialogId, ct);
|
||||
if (row is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
ApplyCandidatePatch(row, patch);
|
||||
row.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
await _dbContext.SaveChangesAsync(ct);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
async Task<bool> IDiscoveryStore.SetCandidateStatusAsync(
|
||||
string dialogId,
|
||||
string status,
|
||||
CancellationToken ct)
|
||||
{
|
||||
DiscCandidateEntity? row = await _dbContext.DiscCandidates
|
||||
.FirstOrDefaultAsync(candidate => candidate.DialogId == dialogId, ct);
|
||||
if (row is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
row.Status = status;
|
||||
row.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
await _dbContext.SaveChangesAsync(ct);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
async Task<bool> IDiscoveryStore.SetCandidateJoinedAsync(
|
||||
string dialogId,
|
||||
bool autoJoined,
|
||||
CancellationToken ct)
|
||||
{
|
||||
DiscCandidateEntity? row = await _dbContext.DiscCandidates
|
||||
.FirstOrDefaultAsync(candidate => candidate.DialogId == dialogId, ct);
|
||||
if (row is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
row.Status = "joined";
|
||||
row.AutoJoined = autoJoined;
|
||||
row.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
await _dbContext.SaveChangesAsync(ct);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
async Task<int?> IDiscoveryStore.IncrementJoinFailuresAsync(string dialogId, CancellationToken ct)
|
||||
{
|
||||
DiscCandidateEntity? row = await _dbContext.DiscCandidates
|
||||
.FirstOrDefaultAsync(candidate => candidate.DialogId == dialogId, ct);
|
||||
if (row is null || row.Status != "review")
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
row.JoinFailures += 1;
|
||||
row.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
await _dbContext.SaveChangesAsync(ct);
|
||||
return row.JoinFailures;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
async Task<bool> IDiscoveryStore.SetCandidateRejectedAsync(string dialogId, CancellationToken ct)
|
||||
{
|
||||
DiscCandidateEntity? row = await _dbContext.DiscCandidates
|
||||
.FirstOrDefaultAsync(candidate => candidate.DialogId == dialogId, ct);
|
||||
if (row is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
row.Status = "rejected";
|
||||
row.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
await _dbContext.SaveChangesAsync(ct);
|
||||
return true;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
using Deal.Infrastructure.Persistence.Entities;
|
||||
using Deal.Modules.Discovery.Application.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Deal.Modules.Discovery.Application.Abstractions;
|
||||
|
||||
namespace Deal.Infrastructure.Persistence.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// Лог Discovery — partial-часть <see cref="DiscoveryStore"/>
|
||||
/// </summary>
|
||||
public sealed partial class DiscoveryStore
|
||||
{
|
||||
/// <inheritdoc />
|
||||
async Task IDiscoveryStore.AddLogAsync(
|
||||
string logId,
|
||||
string taskId,
|
||||
string logEvent,
|
||||
string text,
|
||||
CancellationToken ct)
|
||||
{
|
||||
_dbContext.DiscLog.Add(new DiscLogEntity
|
||||
{
|
||||
Id = logId,
|
||||
TaskId = taskId,
|
||||
Event = logEvent,
|
||||
Text = text,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
});
|
||||
await _dbContext.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
async Task<int> IDiscoveryStore.CountLogEventAsync(
|
||||
string logEvent,
|
||||
DateTimeOffset sinceUtc,
|
||||
CancellationToken ct)
|
||||
{
|
||||
return await _dbContext.DiscLog.CountAsync(row => row.Event == logEvent && row.CreatedAt >= sinceUtc, ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
async Task<IReadOnlyList<DiscoveryLogDto>> IDiscoveryStore.ListTaskLogAsync(
|
||||
string taskId,
|
||||
int limit,
|
||||
CancellationToken ct)
|
||||
{
|
||||
List<DiscLogEntity> rows = await _dbContext.DiscLog
|
||||
.AsNoTracking()
|
||||
.Where(log => log.TaskId == taskId)
|
||||
.OrderByDescending(log => log.CreatedAt)
|
||||
.Take(limit)
|
||||
.ToListAsync(ct);
|
||||
return rows.Select(ToLogDto).ToList();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
using Deal.Infrastructure.Persistence.Entities;
|
||||
using Deal.Modules.Discovery.Application.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Deal.Modules.Discovery.Application.Abstractions;
|
||||
|
||||
namespace Deal.Infrastructure.Persistence.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// Задачи Discovery — partial-часть <see cref="DiscoveryStore"/>
|
||||
/// </summary>
|
||||
public sealed partial class DiscoveryStore
|
||||
{
|
||||
/// <inheritdoc />
|
||||
async Task<IReadOnlyList<DiscoveryTaskDto>> IDiscoveryStore.ListTasksAsync(CancellationToken ct)
|
||||
{
|
||||
List<DiscTaskEntity> rows = await _dbContext.DiscTasks
|
||||
.AsNoTracking()
|
||||
.OrderBy(task => task.CreatedAt)
|
||||
.ToListAsync(ct);
|
||||
return rows.Select(ToTaskDto).ToList();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
async Task<DiscoveryTaskDto?> IDiscoveryStore.GetTaskAsync(string taskId, CancellationToken ct)
|
||||
{
|
||||
DiscTaskEntity? row = await _dbContext.DiscTasks
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(task => task.Id == taskId, ct);
|
||||
return row is null ? null : ToTaskDto(row);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
async Task IDiscoveryStore.CreateTaskAsync(DiscoveryTaskRow row, CancellationToken ct)
|
||||
{
|
||||
DateTimeOffset now = DateTimeOffset.UtcNow;
|
||||
_dbContext.DiscTasks.Add(new DiscTaskEntity
|
||||
{
|
||||
Id = row.Id,
|
||||
Name = row.Name,
|
||||
Description = row.Description,
|
||||
KeywordsJson = ToJson(row.Keywords),
|
||||
MinSubscribers = row.MinSubscribers,
|
||||
Lang = row.Lang,
|
||||
Threshold = row.Threshold,
|
||||
SampleSize = row.SampleSize,
|
||||
PlanJoins = row.PlanJoins,
|
||||
AutoJoin = row.AutoJoin,
|
||||
Status = "draft",
|
||||
CreatedAt = now,
|
||||
UpdatedAt = now,
|
||||
});
|
||||
await _dbContext.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
async Task<bool> IDiscoveryStore.PatchTaskAsync(
|
||||
string taskId,
|
||||
DiscoveryTaskPatch patch,
|
||||
CancellationToken ct)
|
||||
{
|
||||
DiscTaskEntity? row = await _dbContext.DiscTasks
|
||||
.FirstOrDefaultAsync(task => task.Id == taskId, ct);
|
||||
if (row is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
ApplyTaskPatch(row, patch);
|
||||
row.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
await _dbContext.SaveChangesAsync(ct);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
async Task<bool> IDiscoveryStore.DeleteTaskAsync(string taskId, CancellationToken ct)
|
||||
{
|
||||
DiscTaskEntity? row = await _dbContext.DiscTasks
|
||||
.FirstOrDefaultAsync(task => task.Id == taskId, ct);
|
||||
if (row is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
await _dbContext.DiscCandidates.Where(candidate => candidate.TaskId == taskId).ExecuteDeleteAsync(ct);
|
||||
await _dbContext.DiscLog.Where(log => log.TaskId == taskId).ExecuteDeleteAsync(ct);
|
||||
_dbContext.DiscTasks.Remove(row);
|
||||
await _dbContext.SaveChangesAsync(ct);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
async Task<bool> IDiscoveryStore.SetTaskRunningAsync(
|
||||
string taskId,
|
||||
bool resetProgress,
|
||||
CancellationToken ct)
|
||||
{
|
||||
DiscTaskEntity? row = await _dbContext.DiscTasks
|
||||
.FirstOrDefaultAsync(task => task.Id == taskId, ct);
|
||||
if (row is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
row.Status = "running";
|
||||
if (resetProgress)
|
||||
{
|
||||
row.SearchIdx = 0;
|
||||
row.SearchDone = false;
|
||||
row.Found = 0;
|
||||
row.Evaluated = 0;
|
||||
row.Joined = 0;
|
||||
row.Rejected = 0;
|
||||
}
|
||||
|
||||
row.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
await _dbContext.SaveChangesAsync(ct);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
async Task<bool> IDiscoveryStore.SetTaskPausedAsync(string taskId, CancellationToken ct)
|
||||
{
|
||||
DiscTaskEntity? row = await _dbContext.DiscTasks
|
||||
.FirstOrDefaultAsync(task => task.Id == taskId, ct);
|
||||
if (row is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
row.Status = "paused";
|
||||
row.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
await _dbContext.SaveChangesAsync(ct);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
async Task<bool> IDiscoveryStore.SetTaskDoneAsync(string taskId, CancellationToken ct)
|
||||
{
|
||||
DiscTaskEntity? row = await _dbContext.DiscTasks
|
||||
.FirstOrDefaultAsync(task => task.Id == taskId, ct);
|
||||
if (row is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
row.Status = "done";
|
||||
row.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
await _dbContext.SaveChangesAsync(ct);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
async Task<bool> IDiscoveryStore.BumpTaskCounterAsync(
|
||||
string taskId,
|
||||
DiscoveryCounterField field,
|
||||
int n,
|
||||
CancellationToken ct)
|
||||
{
|
||||
DiscTaskEntity? row = await _dbContext.DiscTasks
|
||||
.FirstOrDefaultAsync(task => task.Id == taskId, ct);
|
||||
if (row is null || n <= 0)
|
||||
{
|
||||
return row is not null;
|
||||
}
|
||||
|
||||
switch (field)
|
||||
{
|
||||
case DiscoveryCounterField.Found:
|
||||
row.Found += n;
|
||||
break;
|
||||
case DiscoveryCounterField.Evaluated:
|
||||
row.Evaluated += n;
|
||||
break;
|
||||
case DiscoveryCounterField.Joined:
|
||||
row.Joined += n;
|
||||
break;
|
||||
case DiscoveryCounterField.Rejected:
|
||||
row.Rejected += n;
|
||||
break;
|
||||
default:
|
||||
throw new ArgumentOutOfRangeException(nameof(field), field, "Неизвестный счётчик задачи");
|
||||
}
|
||||
|
||||
row.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
await _dbContext.SaveChangesAsync(ct);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
async Task<bool> IDiscoveryStore.AdvanceSearchAsync(
|
||||
string taskId,
|
||||
int nextIndex,
|
||||
bool searchDone,
|
||||
CancellationToken ct)
|
||||
{
|
||||
DiscTaskEntity? row = await _dbContext.DiscTasks
|
||||
.FirstOrDefaultAsync(task => task.Id == taskId, ct);
|
||||
if (row is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
row.SearchIdx = nextIndex;
|
||||
row.SearchDone = searchDone;
|
||||
row.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
await _dbContext.SaveChangesAsync(ct);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
async Task<int> IDiscoveryStore.SumActivePlanAsync(string? excludeTaskId, CancellationToken ct)
|
||||
{
|
||||
IQueryable<DiscTaskEntity> query = _dbContext.DiscTasks
|
||||
.Where(task => task.Status != "done" && task.Status != "failed");
|
||||
if (excludeTaskId is not null)
|
||||
{
|
||||
query = query.Where(task => task.Id != excludeTaskId);
|
||||
}
|
||||
|
||||
return await query.SumAsync(task => task.PlanJoins, ct);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
using System.Text.Json;
|
||||
using Deal.Infrastructure.Persistence.Entities;
|
||||
using Deal.Modules.Discovery.Application.Abstractions;
|
||||
using Deal.Modules.Discovery.Application.Models;
|
||||
|
||||
namespace Deal.Infrastructure.Persistence.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// EF-адаптер хранилища Discovery
|
||||
/// </summary>
|
||||
public sealed partial class DiscoveryStore : IDiscoveryStore
|
||||
{
|
||||
private readonly TenantDbContext _dbContext;
|
||||
|
||||
/// <summary>
|
||||
/// Создаёт EF-адаптер хранилища Discovery
|
||||
/// </summary>
|
||||
/// <param name="dbContext">Scoped-контекст тенанта запроса (search_path).</param>
|
||||
public DiscoveryStore(TenantDbContext dbContext)
|
||||
{
|
||||
_dbContext = dbContext;
|
||||
}
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
PropertyNameCaseInsensitive = true,
|
||||
};
|
||||
private static DiscoveryTaskDto ToTaskDto(DiscTaskEntity row)
|
||||
{
|
||||
return new DiscoveryTaskDto(
|
||||
row.Id,
|
||||
row.Name,
|
||||
row.Description,
|
||||
FromJson<string>(row.KeywordsJson),
|
||||
row.MinSubscribers,
|
||||
row.Lang,
|
||||
row.Threshold,
|
||||
row.SampleSize,
|
||||
row.PlanJoins,
|
||||
row.AutoJoin,
|
||||
row.Status,
|
||||
row.SearchIdx,
|
||||
row.SearchDone,
|
||||
row.Found,
|
||||
row.Evaluated,
|
||||
row.Joined,
|
||||
row.Rejected,
|
||||
row.CreatedAt.ToUnixTimeMilliseconds(),
|
||||
row.UpdatedAt.ToUnixTimeMilliseconds());
|
||||
}
|
||||
|
||||
private static DiscoveryCandidateDto ToCandidateDto(DiscCandidateEntity row)
|
||||
{
|
||||
return new DiscoveryCandidateDto(
|
||||
row.DialogId,
|
||||
row.TaskId,
|
||||
row.Name,
|
||||
row.Username,
|
||||
row.Kind,
|
||||
row.Hue,
|
||||
row.Participants,
|
||||
row.LangRu,
|
||||
FromJson<string>(row.MarksJson),
|
||||
FromJson<DiscoveryTopicDto>(row.TopicsJson),
|
||||
row.FitRatio,
|
||||
row.Status,
|
||||
row.AutoJoined,
|
||||
row.JoinFailures,
|
||||
row.CreatedAt.ToUnixTimeMilliseconds(),
|
||||
row.UpdatedAt.ToUnixTimeMilliseconds());
|
||||
}
|
||||
|
||||
private static DiscoveryBlacklistDto ToBlacklistDto(DiscBlacklistEntity row)
|
||||
{
|
||||
return new DiscoveryBlacklistDto(row.DialogId, row.Name, row.Reason, row.CreatedAt.ToUnixTimeMilliseconds());
|
||||
}
|
||||
|
||||
private static DiscoveryLogDto ToLogDto(DiscLogEntity row)
|
||||
{
|
||||
return new DiscoveryLogDto(row.Id, row.TaskId, row.Event, row.Text, row.CreatedAt.ToUnixTimeMilliseconds());
|
||||
}
|
||||
|
||||
private static void ApplyTaskPatch(DiscTaskEntity row, DiscoveryTaskPatch patch)
|
||||
{
|
||||
if (patch.Name is not null)
|
||||
{
|
||||
row.Name = patch.Name;
|
||||
}
|
||||
|
||||
if (patch.Description is not null)
|
||||
{
|
||||
row.Description = patch.Description;
|
||||
}
|
||||
|
||||
if (patch.Keywords is not null)
|
||||
{
|
||||
row.KeywordsJson = ToJson(patch.Keywords);
|
||||
}
|
||||
|
||||
if (patch.MinSubscribers is int minSubscribers)
|
||||
{
|
||||
row.MinSubscribers = minSubscribers;
|
||||
}
|
||||
|
||||
if (patch.Lang is not null)
|
||||
{
|
||||
row.Lang = patch.Lang;
|
||||
}
|
||||
|
||||
if (patch.Threshold is int threshold)
|
||||
{
|
||||
row.Threshold = threshold;
|
||||
}
|
||||
|
||||
if (patch.SampleSize is int sampleSize)
|
||||
{
|
||||
row.SampleSize = sampleSize;
|
||||
}
|
||||
|
||||
if (patch.PlanJoins is int planJoins)
|
||||
{
|
||||
row.PlanJoins = planJoins;
|
||||
}
|
||||
|
||||
if (patch.AutoJoin is bool autoJoin)
|
||||
{
|
||||
row.AutoJoin = autoJoin;
|
||||
}
|
||||
}
|
||||
|
||||
private static void ApplyCandidatePatch(DiscCandidateEntity row, DiscoveryCandidatePatch patch)
|
||||
{
|
||||
if (patch.Name is not null)
|
||||
{
|
||||
row.Name = patch.Name;
|
||||
}
|
||||
|
||||
if (patch.Username is not null)
|
||||
{
|
||||
row.Username = patch.Username;
|
||||
}
|
||||
|
||||
if (patch.Kind is not null)
|
||||
{
|
||||
row.Kind = patch.Kind;
|
||||
}
|
||||
|
||||
if (patch.Hue is not null)
|
||||
{
|
||||
row.Hue = patch.Hue;
|
||||
}
|
||||
|
||||
if (patch.Participants is int participants)
|
||||
{
|
||||
row.Participants = participants;
|
||||
}
|
||||
|
||||
if (patch.LangRu is bool langRu)
|
||||
{
|
||||
row.LangRu = langRu;
|
||||
}
|
||||
|
||||
if (patch.Marks is not null)
|
||||
{
|
||||
row.MarksJson = ToJson(patch.Marks);
|
||||
}
|
||||
|
||||
if (patch.Topics is not null)
|
||||
{
|
||||
row.TopicsJson = ToJson(patch.Topics);
|
||||
}
|
||||
|
||||
if (patch.FitRatio is double fitRatio)
|
||||
{
|
||||
row.FitRatio = fitRatio;
|
||||
}
|
||||
|
||||
if (patch.AutoJoined is bool autoJoined)
|
||||
{
|
||||
row.AutoJoined = autoJoined;
|
||||
}
|
||||
}
|
||||
|
||||
private static IReadOnlyList<T> FromJson<T>(string json)
|
||||
{
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<List<T>>(json, JsonOptions) ?? [];
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Сериализует значение в JSON (camelCase, конвенция value_json).
|
||||
private static string ToJson<T>(T value) => JsonSerializer.Serialize(value, JsonOptions);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
using Deal.Infrastructure.Persistence.Entities;
|
||||
using Deal.Modules.Settings.Application.Abstractions;
|
||||
using Deal.Modules.Settings.Application.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Deal.Infrastructure.Persistence.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// EF-адаптер KV-хранилища глобальных
|
||||
/// </summary>
|
||||
public sealed class GlobalSettingsStore(DealDbContext dbContext) : IGlobalSettingsStore
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<SettingValue?> GetAsync(string key, CancellationToken ct)
|
||||
{
|
||||
var entity = await dbContext.GlobalSettings
|
||||
.AsNoTracking()
|
||||
.SingleOrDefaultAsync(s => s.Key == key, ct);
|
||||
return entity is null ? null : new SettingValue(entity.Key, entity.Value, entity.UpdatedAt);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task SetAsync(
|
||||
string key,
|
||||
string valueJson,
|
||||
CancellationToken ct)
|
||||
{
|
||||
// Upsert по ключу (PK): существующая строка обновляется, отсутствующая — добавляется.
|
||||
GlobalSettingEntity? entity = await dbContext.GlobalSettings
|
||||
.SingleOrDefaultAsync(s => s.Key == key, ct);
|
||||
if (entity is null)
|
||||
{
|
||||
dbContext.GlobalSettings.Add(new GlobalSettingEntity
|
||||
{
|
||||
Key = key,
|
||||
Value = valueJson,
|
||||
UpdatedAt = DateTimeOffset.UtcNow,
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
entity.Value = valueJson;
|
||||
entity.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
await dbContext.SaveChangesAsync(ct);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
using Deal.Infrastructure.Persistence.Entities;
|
||||
using Deal.Modules.Tenants.Application.Abstractions;
|
||||
using Deal.Modules.Tenants.Application.Models;
|
||||
using Deal.Modules.Tenants.Application.Services;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Deal.Infrastructure.Persistence.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// EF-адаптер хранилища приглашений
|
||||
/// </summary>
|
||||
public sealed class InviteStore(DealDbContext dbContext) : IInviteStore
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task CreateAsync(InviteDto invite, CancellationToken ct)
|
||||
{
|
||||
dbContext.Invites.Add(new InviteEntity
|
||||
{
|
||||
Code = invite.Code,
|
||||
Email = invite.Email,
|
||||
TenantId = invite.TenantId,
|
||||
Status = invite.Status,
|
||||
ExpiresAt = invite.ExpiresAt,
|
||||
ActivatedAt = invite.ActivatedAt,
|
||||
CreatedById = invite.CreatedById,
|
||||
CreatedAt = invite.CreatedAt,
|
||||
});
|
||||
await dbContext.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<InviteDto?> GetByCodeAsync(string code, CancellationToken ct)
|
||||
{
|
||||
var entity = await dbContext.Invites
|
||||
.AsNoTracking()
|
||||
.SingleOrDefaultAsync(i => i.Code == code, ct);
|
||||
return entity is null ? null : ToInviteDto(entity);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<InviteDto>> ListAsync(CancellationToken ct)
|
||||
{
|
||||
var entities = await dbContext.Invites
|
||||
.AsNoTracking()
|
||||
.OrderByDescending(i => i.CreatedAt)
|
||||
.ToListAsync(ct);
|
||||
return entities.Select(ToInviteDto).ToList();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> UpdateStatusAsync(
|
||||
string code,
|
||||
string status,
|
||||
DateTimeOffset? activatedAt,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var entity = await dbContext.Invites.SingleOrDefaultAsync(i => i.Code == code, ct);
|
||||
if (entity is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
entity.Status = status;
|
||||
entity.ActivatedAt = activatedAt;
|
||||
await dbContext.SaveChangesAsync(ct);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> TryActivateAsync(
|
||||
string code,
|
||||
DateTimeOffset activatedAt,
|
||||
CancellationToken ct)
|
||||
{
|
||||
int affected = await dbContext.Invites
|
||||
.Where(i => i.Code == code && i.Status == InviteStatuses.Pending)
|
||||
.ExecuteUpdateAsync(s => s
|
||||
.SetProperty(i => i.Status, InviteStatuses.Activated)
|
||||
.SetProperty(i => i.ActivatedAt, activatedAt), ct);
|
||||
return affected > 0;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<InviteDto?> FindActiveByEmailAsync(string email, CancellationToken ct)
|
||||
{
|
||||
// «Активное» = статус pending (зеркало частичного unique-индекса InviteConfiguration: WHERE Status='pending').
|
||||
// Протухшее, но ещё не помеченное pending-приглашение тоже вернётся — переводом в expired занимается сервис.
|
||||
var entity = await dbContext.Invites
|
||||
.AsNoTracking()
|
||||
.Where(i => i.Status == InviteStatuses.Pending && i.Email == email)
|
||||
.FirstOrDefaultAsync(ct);
|
||||
return entity is null ? null : ToInviteDto(entity);
|
||||
}
|
||||
|
||||
private static InviteDto ToInviteDto(InviteEntity entity) =>
|
||||
new(
|
||||
entity.Code,
|
||||
entity.Email,
|
||||
entity.TenantId,
|
||||
entity.Status,
|
||||
entity.ExpiresAt,
|
||||
entity.ActivatedAt,
|
||||
entity.CreatedById,
|
||||
entity.CreatedAt);
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
using Deal.Infrastructure.Persistence.Entities;
|
||||
using Deal.Modules.Cards.Application.Sources;
|
||||
using Deal.Modules.Kanban.Application.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Deal.Modules.Kanban.Application.Abstractions;
|
||||
|
||||
namespace Deal.Infrastructure.Persistence.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// Карточки канбана — partial-часть <see cref="KanbanStore"/>
|
||||
/// </summary>
|
||||
public sealed partial class KanbanStore
|
||||
{
|
||||
/// <inheritdoc />
|
||||
async Task<IReadOnlyList<CardDto>> ICardStore.ListCardsAsync(CardsQuery query, CancellationToken ct)
|
||||
{
|
||||
IQueryable<CardEntity> queryable = _dbContext.Cards.AsNoTracking();
|
||||
if (query.Col is null)
|
||||
{
|
||||
queryable = queryable.Where(card => !SelectedStageIds.Contains(card.Col));
|
||||
}
|
||||
else
|
||||
{
|
||||
queryable = queryable.Where(card => card.Col == query.Col);
|
||||
}
|
||||
|
||||
List<CardEntity> entities = await queryable
|
||||
.OrderByDescending(card => card.ReceivedAt)
|
||||
.ToListAsync(ct);
|
||||
return await ToCardDtosAsync(entities, ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
async Task<IReadOnlyList<CardDto>> ICardStore.SearchCardsAsync(
|
||||
string q,
|
||||
int limit,
|
||||
CancellationToken ct)
|
||||
{
|
||||
string query = q.Trim();
|
||||
if (query.Length == 0)
|
||||
{
|
||||
return Array.Empty<CardDto>();
|
||||
}
|
||||
|
||||
string pattern = $"%{query}%";
|
||||
List<CardEntity> entities = await _dbContext.Cards
|
||||
.FromSqlInterpolated(
|
||||
$"""
|
||||
SELECT * FROM "Cards"
|
||||
WHERE "Col" <> ALL({SelectedStageIds})
|
||||
AND ("SearchTsv" @@ plainto_tsquery('russian', {query})
|
||||
OR lower("Title") LIKE {pattern}
|
||||
OR lower("Summary") 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}
|
||||
""")
|
||||
.AsNoTracking()
|
||||
.ToListAsync(ct);
|
||||
return await ToCardDtosAsync(entities, ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
async Task<CardDto?> ICardStore.GetCardAsync(string cardId, CancellationToken ct)
|
||||
{
|
||||
CardEntity? entity = await _dbContext.Cards
|
||||
.AsNoTracking()
|
||||
.SingleOrDefaultAsync(card => card.Id == cardId, ct);
|
||||
if (entity is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
IReadOnlyList<CardDto> cards = await ToCardDtosAsync([entity], ct);
|
||||
return cards[0];
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
async Task<CardDto?> ICardStore.GetCardBySourceAsync(
|
||||
SourceRef source,
|
||||
CancellationToken ct)
|
||||
{
|
||||
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.SourceKind == source.Kind
|
||||
&& card.SourceExternalId == externalId
|
||||
&& card.SourceOriginRef == originRef)
|
||||
.OrderByDescending(card => card.ReceivedAt)
|
||||
.FirstOrDefaultAsync(ct);
|
||||
if (entity is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
IReadOnlyList<CardDto> cards = await ToCardDtosAsync([entity], ct);
|
||||
return cards[0];
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
async Task ICardStore.AddCardAsync(CardSnapshot snapshot, CancellationToken ct)
|
||||
{
|
||||
// CreatedAt проставляет хранилище (UTC-now) — в snapshot поля нет (см. CardSnapshot).
|
||||
_dbContext.Cards.Add(ToCardEntity(snapshot));
|
||||
await _dbContext.SaveChangesAsync(ct);
|
||||
|
||||
// Стартовые комментарии (ручное создание): таблица LeadComments, CreatedAt — один «now» вставки.
|
||||
if (snapshot.Comments.Count > 0)
|
||||
{
|
||||
DateTimeOffset commentAt = DateTimeOffset.UtcNow;
|
||||
foreach (CardCommentDto comment in snapshot.Comments)
|
||||
{
|
||||
_dbContext.LeadComments.Add(new LeadCommentEntity
|
||||
{
|
||||
Id = comment.Id,
|
||||
CardId = snapshot.Id,
|
||||
By = comment.By,
|
||||
Text = comment.Text,
|
||||
CreatedAt = commentAt,
|
||||
});
|
||||
}
|
||||
|
||||
await _dbContext.SaveChangesAsync(ct);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
async Task ICardStore.UpdateColumnAsync(CardColumnUpdateDto update, CancellationToken ct)
|
||||
{
|
||||
CardEntity? entity = await _dbContext.Cards.SingleOrDefaultAsync(card => card.Id == update.CardId, ct);
|
||||
if (entity is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
entity.Col = update.Col;
|
||||
entity.IsNew = update.IsNew;
|
||||
if (update.PrevCol is not null)
|
||||
{
|
||||
entity.PrevCol = update.PrevCol;
|
||||
}
|
||||
|
||||
entity.ArchivedAt = update.ArchivedAt;
|
||||
entity.MatchHitsJson = ToJson(update.MatchHits);
|
||||
await _dbContext.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
async Task<bool> ICardStore.ApplyReclassificationAsync(CardReclassificationDto update, CancellationToken ct)
|
||||
{
|
||||
CardEntity? entity = await _dbContext.Cards.SingleOrDefaultAsync(card => card.Id == update.CardId, ct);
|
||||
if (entity is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
entity.Col = update.Col;
|
||||
entity.IsNew = update.IsNew;
|
||||
entity.IsVacancy = update.IsVacancy;
|
||||
entity.IsVacancyKnown = update.IsVacancyKnown;
|
||||
entity.Title = update.Title;
|
||||
entity.Summary = update.Summary;
|
||||
entity.StackJson = ToJson(update.Stack);
|
||||
entity.BudgetFrom = update.Budget?.From;
|
||||
entity.BudgetTo = update.Budget?.To;
|
||||
entity.BudgetCur = update.Budget?.Cur ?? string.Empty;
|
||||
entity.ConvFrom = update.Converted?.From;
|
||||
entity.ConvTo = update.Converted?.To;
|
||||
entity.ConvCur = update.Converted?.Cur ?? string.Empty;
|
||||
entity.Contact = update.Contact;
|
||||
entity.ContactsJson = ToJson(update.Contacts);
|
||||
entity.MatchHitsJson = ToJson(update.MatchHits);
|
||||
entity.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
await _dbContext.SaveChangesAsync(ct);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
async Task ICardStore.UpdateSeenAsync(
|
||||
string? cardId,
|
||||
string? col,
|
||||
CancellationToken ct)
|
||||
{
|
||||
IQueryable<CardEntity> queryable = _dbContext.Cards;
|
||||
if (cardId is not null)
|
||||
{
|
||||
queryable = queryable.Where(card => card.Id == cardId);
|
||||
}
|
||||
else if (col is not null)
|
||||
{
|
||||
queryable = queryable.Where(card => card.Col == col);
|
||||
}
|
||||
|
||||
await queryable.ExecuteUpdateAsync(setters => setters.SetProperty(card => card.IsNew, false), ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
async Task ICardStore.DeleteForeverAsync(string cardId, CancellationToken ct)
|
||||
{
|
||||
await using var transaction = await _dbContext.Database.BeginTransactionAsync(ct);
|
||||
await _dbContext.DedupEntries
|
||||
.Where(entry => entry.LeadId == cardId)
|
||||
.ExecuteDeleteAsync(ct);
|
||||
await _dbContext.Cards
|
||||
.Where(card => card.Id == cardId)
|
||||
.ExecuteDeleteAsync(ct);
|
||||
await transaction.CommitAsync(ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
async Task<int> ICardStore.ClearColAsync(string col, CancellationToken ct)
|
||||
{
|
||||
await using var transaction = await _dbContext.Database.BeginTransactionAsync(ct);
|
||||
await _dbContext.DedupEntries
|
||||
.Where(entry => _dbContext.Cards.Any(card => card.Col == col && card.Id == entry.LeadId))
|
||||
.ExecuteDeleteAsync(ct);
|
||||
int cleared = await _dbContext.Cards
|
||||
.Where(card => card.Col == col)
|
||||
.ExecuteDeleteAsync(ct);
|
||||
await transaction.CommitAsync(ct);
|
||||
return cleared;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
async Task<IReadOnlyDictionary<string, CardColumnCountDto>> ICardStore.CountCardsByColAsync(CancellationToken ct)
|
||||
{
|
||||
var rows = await _dbContext.Cards
|
||||
.AsNoTracking()
|
||||
.Where(card => !SelectedStageIds.Contains(card.Col))
|
||||
.GroupBy(card => card.Col)
|
||||
.Select(group => new { Col = group.Key, Count = group.Count(), NewCount = group.Count(card => card.IsNew) })
|
||||
.ToListAsync(ct);
|
||||
|
||||
var result = new Dictionary<string, CardColumnCountDto>();
|
||||
foreach (var row in rows)
|
||||
{
|
||||
result.Add(row.Col, new CardColumnCountDto(row.Count, row.NewCount));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
using Deal.Infrastructure.Persistence.Entities;
|
||||
using Deal.Modules.Cards.Application.Models;
|
||||
using Deal.Modules.Kanban.Application.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Deal.Modules.Kanban.Application.Abstractions;
|
||||
|
||||
namespace Deal.Infrastructure.Persistence.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// Комментарии и журнал действий — partial-часть <see cref="KanbanStore"/>
|
||||
/// </summary>
|
||||
public sealed partial class KanbanStore
|
||||
{
|
||||
/// <inheritdoc />
|
||||
async Task<IReadOnlyList<CardCommentDto>> ICardStore.ListCommentsAsync(string cardId, CancellationToken ct)
|
||||
{
|
||||
List<LeadCommentEntity> entities = await _dbContext.LeadComments
|
||||
.AsNoTracking()
|
||||
.Where(comment => comment.CardId == cardId)
|
||||
.OrderBy(comment => comment.CreatedAt)
|
||||
.ThenBy(comment => comment.Id)
|
||||
.ToListAsync(ct);
|
||||
return entities.Select(ToCommentDto).ToList();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
async Task ICardStore.AddCommentAsync(
|
||||
string commentId,
|
||||
string cardId,
|
||||
string by,
|
||||
string text,
|
||||
CancellationToken ct)
|
||||
{
|
||||
_dbContext.LeadComments.Add(new LeadCommentEntity
|
||||
{
|
||||
Id = commentId,
|
||||
CardId = cardId,
|
||||
By = by,
|
||||
Text = text,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
});
|
||||
await _dbContext.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
async Task ICardStore.AddMoveAsync(CardMoveDto move, CancellationToken ct)
|
||||
{
|
||||
_dbContext.CardMoves.Add(new CardMoveEntity
|
||||
{
|
||||
Id = move.Id,
|
||||
LeadId = move.LeadId,
|
||||
Action = move.Action,
|
||||
FromCol = move.FromCol,
|
||||
ToCol = move.ToCol,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
});
|
||||
await _dbContext.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<int> CountMovesAsync(CancellationToken ct) => _dbContext.CardMoves.CountAsync(ct);
|
||||
|
||||
/// <inheritdoc />
|
||||
async Task<IReadOnlyList<AiMarkupExampleDto>> ICardStore.GetAiMarkupExamplesAsync(int limit, CancellationToken ct)
|
||||
{
|
||||
return await _dbContext.CardMoves
|
||||
.AsNoTracking()
|
||||
.Where(move => move.ToCol != null && move.ToCol != CardIds.Trash && move.ToCol != CardIds.Archive)
|
||||
.Join(
|
||||
_dbContext.Cards.AsNoTracking(),
|
||||
move => move.LeadId,
|
||||
card => card.Id,
|
||||
(move, card) => new { move, card })
|
||||
.Where(joined => (joined.move.Action == MoveAction || joined.move.Action == RestoreAction)
|
||||
&& joined.card.SourceText.Length > 0)
|
||||
.OrderByDescending(joined => joined.move.CreatedAt)
|
||||
.Take(limit)
|
||||
.Select(joined => new AiMarkupExampleDto(joined.card.SourceText, joined.move.ToCol!))
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
using Deal.Infrastructure.Persistence.Entities;
|
||||
using Deal.Modules.Cards.Application.Models;
|
||||
using Deal.Modules.Kanban.Application.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Deal.Modules.Kanban.Application.Abstractions;
|
||||
|
||||
namespace Deal.Infrastructure.Persistence.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// Контейнеры карточек — partial-часть <see cref="KanbanStore"/>
|
||||
/// </summary>
|
||||
public sealed partial class KanbanStore
|
||||
{
|
||||
/// <inheritdoc />
|
||||
async Task<IReadOnlyList<ContainerDto>> ICardStore.ListContainersAsync(string? space, CancellationToken ct)
|
||||
{
|
||||
IQueryable<ContainerEntity> queryable = _dbContext.Containers.AsNoTracking();
|
||||
if (space is not null)
|
||||
{
|
||||
queryable = queryable.Where(container => container.Space == space);
|
||||
}
|
||||
|
||||
// Принятые колонки идут первыми, ИИ-предложения — в конец (как ORDER BY suggested, position).
|
||||
List<ContainerEntity> entities = await queryable
|
||||
.OrderBy(container => container.Suggested)
|
||||
.ThenBy(container => container.Position)
|
||||
.ToListAsync(ct);
|
||||
return entities.Select(ToContainerDto).ToList();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
async Task<ContainerDto?> ICardStore.GetContainerAsync(string containerId, CancellationToken ct)
|
||||
{
|
||||
ContainerEntity? entity = await _dbContext.Containers
|
||||
.AsNoTracking()
|
||||
.SingleOrDefaultAsync(container => container.Id == containerId, ct);
|
||||
return entity is null ? null : ToContainerDto(entity);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
async Task ICardStore.CreateContainerAsync(ContainerDto container, CancellationToken ct)
|
||||
{
|
||||
_dbContext.Containers.Add(ToContainerEntity(container));
|
||||
await _dbContext.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
async Task ICardStore.UpdateContainerAsync(ContainerDto container, CancellationToken ct)
|
||||
{
|
||||
// Полное обновление строки (сервис читает Get + применяет ContainerPatchDto): JSON-поля пишутся
|
||||
// целиком, CreatedAt не трогаем — одним UPDATE.
|
||||
await _dbContext.Containers
|
||||
.Where(row => row.Id == container.Id)
|
||||
.ExecuteUpdateAsync(setters => setters
|
||||
.SetProperty(row => row.Name, container.Name)
|
||||
.SetProperty(row => row.Description, container.Description)
|
||||
.SetProperty(row => row.Color, container.Color)
|
||||
.SetProperty(row => row.Position, container.Order)
|
||||
.SetProperty(row => row.Collapsed, container.Collapsed)
|
||||
.SetProperty(row => row.Suggested, container.Suggested)
|
||||
.SetProperty(row => row.RulesJson, ToRulesJson(container.Rules))
|
||||
.SetProperty(row => row.Note, container.Note)
|
||||
.SetProperty(row => row.PolicyJson, ToPolicyJson(container.Policy)),
|
||||
ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
async Task<int> ICardStore.DeleteContainerAsync(string containerId, CancellationToken ct)
|
||||
{
|
||||
// Два изменения разных таблиц — в одной транзакции: либо карточки ушли в «Неразобранное» и контейнер
|
||||
// удалён, либо ничего не изменилось.
|
||||
await using var transaction = await _dbContext.Database.BeginTransactionAsync(ct);
|
||||
int moved = await _dbContext.Cards
|
||||
.Where(card => card.Col == containerId)
|
||||
.ExecuteUpdateAsync(setters => setters
|
||||
.SetProperty(card => card.Col, CardIds.Inbox)
|
||||
.SetProperty(card => card.IsNew, true)
|
||||
.SetProperty(card => card.PrevCol, CardIds.Inbox),
|
||||
ct);
|
||||
await _dbContext.Containers
|
||||
.Where(container => container.Id == containerId)
|
||||
.ExecuteDeleteAsync(ct);
|
||||
await transaction.CommitAsync(ct);
|
||||
return moved;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
async Task ICardStore.ReorderContainersAsync(
|
||||
string space,
|
||||
IReadOnlyList<string> containerIds,
|
||||
CancellationToken ct)
|
||||
{
|
||||
// Позиции 0..N-1 — одна транзакция: при сбое середины порядок не остаётся частично проставленным.
|
||||
await using var transaction = await _dbContext.Database.BeginTransactionAsync(ct);
|
||||
for (int i = 0; i < containerIds.Count; i++)
|
||||
{
|
||||
int position = i;
|
||||
string containerId = containerIds[i];
|
||||
await _dbContext.Containers
|
||||
.Where(container => container.Id == containerId && container.Space == space)
|
||||
.ExecuteUpdateAsync(setters => setters.SetProperty(container => container.Position, position), ct);
|
||||
}
|
||||
|
||||
await transaction.CommitAsync(ct);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
using Deal.Infrastructure.Persistence.Entities;
|
||||
using Deal.Modules.Kanban.Application.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Deal.Modules.Kanban.Application.Abstractions;
|
||||
|
||||
namespace Deal.Infrastructure.Persistence.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// Операции карточек пространства «Выбранные» — partial-часть <see cref="KanbanStore"/>
|
||||
/// </summary>
|
||||
public sealed partial class KanbanStore
|
||||
{
|
||||
/// <inheritdoc />
|
||||
async Task<IReadOnlyList<CardDto>> ICardStore.ListSelectedCardsAsync(string? containerId, CancellationToken ct)
|
||||
{
|
||||
IQueryable<CardEntity> queryable = _dbContext.Cards.AsNoTracking();
|
||||
if (containerId is not null)
|
||||
{
|
||||
queryable = queryable.Where(card => card.Col == containerId);
|
||||
}
|
||||
else
|
||||
{
|
||||
queryable = queryable.Where(card => SelectedStageIds.Contains(card.Col));
|
||||
}
|
||||
|
||||
List<CardEntity> entities = await queryable
|
||||
.OrderByDescending(card => card.UpdatedAt)
|
||||
.ToListAsync(ct);
|
||||
return await ToCardDtosAsync(entities, ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
async Task<bool> ICardStore.PatchCardAsync(
|
||||
string cardId,
|
||||
CardPatch patch,
|
||||
CancellationToken ct)
|
||||
{
|
||||
CardEntity? entity = await _dbContext.Cards
|
||||
.SingleOrDefaultAsync(card => card.Id == cardId, ct);
|
||||
if (entity is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
ApplyPatch(entity, patch);
|
||||
entity.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
|
||||
if (patch.Comments is not null)
|
||||
{
|
||||
await _dbContext.LeadComments
|
||||
.Where(comment => comment.CardId == cardId)
|
||||
.ExecuteDeleteAsync(ct);
|
||||
DateTimeOffset commentAt = DateTimeOffset.UtcNow;
|
||||
foreach (CardCommentDto comment in patch.Comments)
|
||||
{
|
||||
_dbContext.LeadComments.Add(new LeadCommentEntity
|
||||
{
|
||||
Id = comment.Id,
|
||||
CardId = cardId,
|
||||
By = comment.By,
|
||||
Text = comment.Text,
|
||||
CreatedAt = commentAt,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await _dbContext.SaveChangesAsync(ct);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
async Task<bool> ICardStore.AddLinkAsync(
|
||||
string cardId,
|
||||
CardLinkDto link,
|
||||
CancellationToken ct)
|
||||
{
|
||||
return await ExecuteJsonMutationAsync(
|
||||
$"""
|
||||
UPDATE "Cards"
|
||||
SET "LinksJson" = (COALESCE(NULLIF("LinksJson", '')::jsonb, '[]'::jsonb)
|
||||
|| jsonb_build_array({ToJson(link)}::jsonb))::text,
|
||||
"UpdatedAt" = {DateTimeOffset.UtcNow}
|
||||
WHERE "Id" = {cardId}
|
||||
""",
|
||||
ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
async Task<bool> ICardStore.RemoveLinkAsync(
|
||||
string cardId,
|
||||
string linkId,
|
||||
CancellationToken ct)
|
||||
{
|
||||
return await ExecuteJsonMutationAsync(
|
||||
$"""
|
||||
UPDATE "Cards"
|
||||
SET "LinksJson" = COALESCE((
|
||||
SELECT jsonb_agg(item ORDER BY ord)
|
||||
FROM jsonb_array_elements(COALESCE(NULLIF("LinksJson", '')::jsonb, '[]'::jsonb))
|
||||
WITH ORDINALITY AS t(item, ord)
|
||||
WHERE item ->> 'id' <> {linkId}
|
||||
), '[]'::jsonb)::text,
|
||||
"UpdatedAt" = {DateTimeOffset.UtcNow}
|
||||
WHERE "Id" = {cardId}
|
||||
""",
|
||||
ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
async Task<bool> ICardStore.AddFileAsync(
|
||||
string cardId,
|
||||
CardFileDto file,
|
||||
CancellationToken ct)
|
||||
{
|
||||
return await ExecuteJsonMutationAsync(
|
||||
$"""
|
||||
UPDATE "Cards"
|
||||
SET "FilesJson" = (COALESCE(NULLIF("FilesJson", '')::jsonb, '[]'::jsonb)
|
||||
|| jsonb_build_array({ToJson(file)}::jsonb))::text,
|
||||
"UpdatedAt" = {DateTimeOffset.UtcNow}
|
||||
WHERE "Id" = {cardId}
|
||||
""",
|
||||
ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
async Task<bool> ICardStore.RemoveFileAsync(
|
||||
string cardId,
|
||||
string fileId,
|
||||
CancellationToken ct)
|
||||
{
|
||||
return await ExecuteJsonMutationAsync(
|
||||
$"""
|
||||
UPDATE "Cards"
|
||||
SET "FilesJson" = COALESCE((
|
||||
SELECT jsonb_agg(item ORDER BY ord)
|
||||
FROM jsonb_array_elements(COALESCE(NULLIF("FilesJson", '')::jsonb, '[]'::jsonb))
|
||||
WITH ORDINALITY AS t(item, ord)
|
||||
WHERE item ->> 'id' <> {fileId}
|
||||
), '[]'::jsonb)::text,
|
||||
"UpdatedAt" = {DateTimeOffset.UtcNow}
|
||||
WHERE "Id" = {cardId}
|
||||
""",
|
||||
ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
async Task<bool> ICardStore.MoveCardStageAsync(
|
||||
string cardId,
|
||||
string containerId,
|
||||
CardHistoryDto historyEntry,
|
||||
long atMs,
|
||||
CancellationToken ct)
|
||||
{
|
||||
CardEntity? entity = await _dbContext.Cards
|
||||
.AsNoTracking()
|
||||
.SingleOrDefaultAsync(card => card.Id == cardId, ct);
|
||||
if (entity is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
List<CardHistoryDto> history = ToJsonList<CardHistoryDto>(entity.HistoryJson).ToList();
|
||||
history.Add(historyEntry);
|
||||
DateTimeOffset at = DateTimeOffset.FromUnixTimeMilliseconds(atMs);
|
||||
|
||||
await _dbContext.Cards
|
||||
.Where(card => card.Id == cardId)
|
||||
.ExecuteUpdateAsync(setters => setters
|
||||
.SetProperty(card => card.Col, containerId)
|
||||
.SetProperty(card => card.ReminderAt, (DateTimeOffset?)null)
|
||||
.SetProperty(card => card.ReminderFired, false)
|
||||
.SetProperty(card => card.UpdatedAt, at)
|
||||
.SetProperty(card => card.HistoryJson, ToJson(history)),
|
||||
ct);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
async Task ICardStore.SetReminderAsync(
|
||||
string cardId,
|
||||
long atMs,
|
||||
CancellationToken ct)
|
||||
{
|
||||
await _dbContext.Cards
|
||||
.Where(card => card.Id == cardId)
|
||||
.ExecuteUpdateAsync(setters => setters
|
||||
.SetProperty(card => card.ReminderAt, DateTimeOffset.FromUnixTimeMilliseconds(atMs))
|
||||
.SetProperty(card => card.ReminderFired, false)
|
||||
.SetProperty(card => card.UpdatedAt, DateTimeOffset.UtcNow),
|
||||
ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
async Task ICardStore.ClearReminderAsync(string cardId, CancellationToken ct)
|
||||
{
|
||||
await _dbContext.Cards
|
||||
.Where(card => card.Id == cardId)
|
||||
.ExecuteUpdateAsync(setters => setters
|
||||
.SetProperty(card => card.ReminderAt, (DateTimeOffset?)null)
|
||||
.SetProperty(card => card.ReminderFired, false),
|
||||
ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
async Task<int> ICardStore.ClearStageAsync(string containerId, CancellationToken ct)
|
||||
{
|
||||
return await _dbContext.Cards
|
||||
.Where(card => card.Col == containerId)
|
||||
.ExecuteDeleteAsync(ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
async Task<IReadOnlyList<CardReminderDueDto>> ICardStore.ListDueRemindersAsync(DateTimeOffset now, CancellationToken ct)
|
||||
{
|
||||
return await _dbContext.Cards
|
||||
.AsNoTracking()
|
||||
.Where(card => card.Col == HoldStage
|
||||
&& card.ReminderAt != null
|
||||
&& !card.ReminderFired
|
||||
&& card.ReminderAt <= now)
|
||||
.OrderBy(card => card.ReminderAt)
|
||||
.Select(card => new CardReminderDueDto(card.Id, card.Title, card.Col))
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
async Task ICardStore.MarkRemindersFiredAsync(IReadOnlyList<string> cardIds, CancellationToken ct)
|
||||
{
|
||||
if (cardIds.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await _dbContext.Cards
|
||||
.Where(card => cardIds.Contains(card.Id))
|
||||
.ExecuteUpdateAsync(setters => setters.SetProperty(card => card.ReminderFired, true), ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
async Task<int> ICardStore.ClearExpiredRemindersAsync(DateTimeOffset now, CancellationToken ct)
|
||||
{
|
||||
return await _dbContext.Cards
|
||||
.Where(card => card.ReminderAt != null && card.ReminderAt <= now)
|
||||
.ExecuteUpdateAsync(setters => setters
|
||||
.SetProperty(card => card.ReminderAt, (DateTimeOffset?)null)
|
||||
.SetProperty(card => card.ReminderFired, false),
|
||||
ct);
|
||||
}
|
||||
|
||||
// Выполняет атомарную JSON-мутацию колонки карточки: один UPDATE, 0 затронутых строк = карточки нет.
|
||||
private async Task<bool> ExecuteJsonMutationAsync(FormattableString sql, CancellationToken ct)
|
||||
{
|
||||
int affected = await _dbContext.Database.ExecuteSqlInterpolatedAsync(sql, ct);
|
||||
return affected == 1;
|
||||
}
|
||||
|
||||
private static void ApplyPatch(CardEntity entity, CardPatch patch)
|
||||
{
|
||||
if (patch.Title is not null)
|
||||
{
|
||||
entity.Title = patch.Title;
|
||||
}
|
||||
|
||||
if (patch.Summary is not null)
|
||||
{
|
||||
entity.Summary = patch.Summary;
|
||||
}
|
||||
|
||||
if (patch.Contact is not null)
|
||||
{
|
||||
entity.Contact = patch.Contact;
|
||||
}
|
||||
|
||||
if (patch.TzText is not null)
|
||||
{
|
||||
entity.TzText = patch.TzText;
|
||||
}
|
||||
|
||||
if (patch.Stack is not null)
|
||||
{
|
||||
entity.StackJson = ToJson(patch.Stack);
|
||||
}
|
||||
|
||||
if (patch.Budget is not null)
|
||||
{
|
||||
entity.BudgetFrom = patch.Budget.From;
|
||||
entity.BudgetTo = patch.Budget.To;
|
||||
entity.BudgetCur = patch.Budget.Cur;
|
||||
}
|
||||
|
||||
if (patch.Links is not null)
|
||||
{
|
||||
entity.LinksJson = ToJson(patch.Links);
|
||||
}
|
||||
|
||||
if (patch.Files is not null)
|
||||
{
|
||||
entity.FilesJson = ToJson(patch.Files);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
using Deal.Infrastructure.Persistence.Entities;
|
||||
using Deal.Modules.Cards.Application.Models;
|
||||
using Deal.Modules.Kanban.Application.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Deal.Modules.Kanban.Application.Abstractions;
|
||||
|
||||
namespace Deal.Infrastructure.Persistence.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// Правила хранения, конверсии и вход эвристики suggest — partial-часть <see cref="KanbanStore"/>
|
||||
/// </summary>
|
||||
public sealed partial class KanbanStore
|
||||
{
|
||||
/// <inheritdoc />
|
||||
async Task<IReadOnlyList<string>> ICardStore.ListArchiveCandidatesAsync(DateTimeOffset receivedBeforeUtc, CancellationToken ct)
|
||||
{
|
||||
List<string> boardIds = await _dbContext.Containers
|
||||
.AsNoTracking()
|
||||
.Where(container => container.Kind == ContainerKinds.Board)
|
||||
.Select(container => container.Id)
|
||||
.ToListAsync(ct);
|
||||
|
||||
return await _dbContext.Cards
|
||||
.AsNoTracking()
|
||||
.Where(card => card.ReceivedAt < receivedBeforeUtc
|
||||
&& (card.Col == CardIds.Inbox || boardIds.Contains(card.Col)))
|
||||
.Select(card => card.Id)
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
async Task<int> ICardStore.ArchiveAsync(
|
||||
IReadOnlyList<string> cardIds,
|
||||
DateTimeOffset archivedAt,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (cardIds.Count == 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
return await _dbContext.Cards
|
||||
.Where(card => cardIds.Contains(card.Id))
|
||||
.ExecuteUpdateAsync(setters => setters
|
||||
.SetProperty(card => card.Col, CardIds.Archive)
|
||||
.SetProperty(card => card.IsNew, false)
|
||||
.SetProperty(card => card.ArchivedAt, archivedAt)
|
||||
.SetProperty(card => card.MatchHitsJson, ToJson(Array.Empty<MatchHitDto>())),
|
||||
ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
async Task<IReadOnlyList<string>> ICardStore.ListExpiredArchiveCandidatesAsync(DateTimeOffset archivedBeforeUtc, CancellationToken ct)
|
||||
{
|
||||
return await _dbContext.Cards
|
||||
.AsNoTracking()
|
||||
.Where(card => card.Col == CardIds.Archive
|
||||
&& card.ArchivedAt != null
|
||||
&& card.ArchivedAt < archivedBeforeUtc)
|
||||
.Select(card => card.Id)
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
async Task<IReadOnlyList<string>> ICardStore.ListTrashCandidatesAsync(DateTimeOffset receivedBeforeUtc, CancellationToken ct)
|
||||
{
|
||||
return await _dbContext.Cards
|
||||
.AsNoTracking()
|
||||
.Where(card => card.Col == CardIds.Trash && card.ReceivedAt < receivedBeforeUtc)
|
||||
.Select(card => card.Id)
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
async Task<int> ICardStore.PurgeAsync(IReadOnlyList<string> cardIds, CancellationToken ct)
|
||||
{
|
||||
if (cardIds.Count == 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
await using var transaction = await _dbContext.Database.BeginTransactionAsync(ct);
|
||||
await _dbContext.DedupEntries
|
||||
.Where(entry => entry.LeadId != null && cardIds.Contains(entry.LeadId!))
|
||||
.ExecuteDeleteAsync(ct);
|
||||
int deleted = await _dbContext.Cards
|
||||
.Where(card => cardIds.Contains(card.Id))
|
||||
.ExecuteDeleteAsync(ct);
|
||||
await transaction.CommitAsync(ct);
|
||||
return deleted;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
async Task<IReadOnlyList<CardDto>> ICardStore.ListCardsForConversionAsync(CancellationToken ct)
|
||||
{
|
||||
List<CardEntity> entities = await _dbContext.Cards
|
||||
.AsNoTracking()
|
||||
.Where(card => card.BudgetCur != string.Empty
|
||||
&& !ConversionExcludedCols.Contains(card.Col))
|
||||
.OrderByDescending(card => card.ReceivedAt)
|
||||
.ToListAsync(ct);
|
||||
return entities.Select(card => ToCardDto(card, Array.Empty<CardCommentDto>())).ToList();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
async Task ICardStore.UpdateConversionAsync(
|
||||
string cardId,
|
||||
double? convFrom,
|
||||
double? convTo,
|
||||
string convCur,
|
||||
CancellationToken ct)
|
||||
{
|
||||
await _dbContext.Cards
|
||||
.Where(card => card.Id == cardId)
|
||||
.ExecuteUpdateAsync(setters => setters
|
||||
.SetProperty(card => card.ConvFrom, convFrom)
|
||||
.SetProperty(card => card.ConvTo, convTo)
|
||||
.SetProperty(card => card.ConvCur, convCur),
|
||||
ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
async Task<IReadOnlyList<CardDto>> ICardStore.ListInboxWithSourceAsync(CancellationToken ct)
|
||||
{
|
||||
List<CardEntity> entities = await _dbContext.Cards
|
||||
.AsNoTracking()
|
||||
.Where(card => card.Col == CardIds.Inbox && card.SourceText != string.Empty)
|
||||
.OrderByDescending(card => card.ReceivedAt)
|
||||
.ToListAsync(ct);
|
||||
return entities.Select(card => ToCardDto(card, Array.Empty<CardCommentDto>())).ToList();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
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;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Deal.Infrastructure.Persistence.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// EF-адаптер хранилища карточек и контейнеров
|
||||
/// </summary>
|
||||
public sealed partial class KanbanStore : ICardStore
|
||||
{
|
||||
private readonly TenantDbContext _dbContext;
|
||||
|
||||
/// <summary>
|
||||
/// Создаёт EF-адаптер хранилища канбана
|
||||
/// </summary>
|
||||
/// <param name="dbContext">Контекст схемы тенанта (Containers/Cards/LeadComments/CardMoves/DedupEntries).</param>
|
||||
public KanbanStore(TenantDbContext dbContext)
|
||||
{
|
||||
_dbContext = dbContext;
|
||||
}
|
||||
|
||||
private const string NoRulesJson = "{}";
|
||||
|
||||
private const string MoveAction = "move";
|
||||
|
||||
private const string RestoreAction = "restore";
|
||||
|
||||
private const long MillisPerMinute = 60_000L;
|
||||
private const long MinutesPerHour = 60L;
|
||||
private const long HoursPerDay = 24L;
|
||||
|
||||
private static readonly string[] ConversionExcludedCols =
|
||||
[CardIds.Archive, CardIds.Trash];
|
||||
|
||||
private static readonly string[] SelectedStageIds = CardsDefaultContainers.Ids.ToArray();
|
||||
|
||||
private const string HoldStage = CardsDefaultContainers.Hold;
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
PropertyNameCaseInsensitive = true,
|
||||
};
|
||||
|
||||
// Читает карточки пачкой с приложенными комментариями (один запрос на все id).
|
||||
// cards: Карточки (уже отсортированы запросом чтения).
|
||||
// ct: Токен отмены.
|
||||
// Возвращает: DTO карточек в порядке входа: JSON-поля разобраны, комментарии приложены, time посчитан.
|
||||
private async Task<IReadOnlyList<CardDto>> ToCardDtosAsync(List<CardEntity> cards, CancellationToken ct)
|
||||
{
|
||||
if (cards.Count == 0)
|
||||
{
|
||||
return Array.Empty<CardDto>();
|
||||
}
|
||||
|
||||
List<string> cardIds = cards.Select(card => card.Id).ToList();
|
||||
List<LeadCommentEntity> comments = await _dbContext.LeadComments
|
||||
.AsNoTracking()
|
||||
.Where(comment => cardIds.Contains(comment.CardId))
|
||||
.OrderBy(comment => comment.CreatedAt)
|
||||
.ThenBy(comment => comment.Id)
|
||||
.ToListAsync(ct);
|
||||
ILookup<string, LeadCommentEntity> commentsByCardId = comments.ToLookup(comment => comment.CardId);
|
||||
|
||||
return cards
|
||||
.Select(card => ToCardDto(card, commentsByCardId[card.Id].Select(ToCommentDto).ToList()))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
// Маппинг строки Containers в DTO (JSON-поля разбираются).
|
||||
private static ContainerDto ToContainerDto(ContainerEntity entity) => new()
|
||||
{
|
||||
Id = entity.Id,
|
||||
Name = entity.Name,
|
||||
Description = entity.Description,
|
||||
Color = entity.Color,
|
||||
Order = entity.Position,
|
||||
Space = entity.Space,
|
||||
Kind = entity.Kind,
|
||||
Collapsed = entity.Collapsed,
|
||||
Suggested = entity.Suggested,
|
||||
Rules = ToRulesOrNull(entity.RulesJson),
|
||||
Policy = ToPolicyOrEmpty(entity.PolicyJson),
|
||||
Note = entity.Note,
|
||||
};
|
||||
|
||||
// Маппинг DTO в строку Containers (JSON-поля сериализуются; CreatedAt — UTC-now).
|
||||
private static ContainerEntity ToContainerEntity(ContainerDto container) => new()
|
||||
{
|
||||
Id = container.Id,
|
||||
Name = container.Name,
|
||||
Description = container.Description,
|
||||
Color = container.Color,
|
||||
Position = container.Order,
|
||||
Space = container.Space,
|
||||
Kind = container.Kind,
|
||||
Collapsed = container.Collapsed,
|
||||
Suggested = container.Suggested,
|
||||
RulesJson = ToRulesJson(container.Rules),
|
||||
PolicyJson = ToPolicyJson(container.Policy),
|
||||
Note = container.Note,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
};
|
||||
|
||||
// Маппинг строки Cards в DTO: JSON-поля разобраны, budget/converted собраны из пар (from,to,cur).
|
||||
// entity: Строка карточки.
|
||||
// comments: Комментарии карточки (уже в DTO с human-меткой time).
|
||||
private static CardDto ToCardDto(CardEntity entity, IReadOnlyList<CardCommentDto> comments) => new()
|
||||
{
|
||||
Id = entity.Id,
|
||||
Col = entity.Col,
|
||||
IsNew = entity.IsNew,
|
||||
Local = entity.Local,
|
||||
IsVacancy = entity.IsVacancy,
|
||||
IsVacancyKnown = entity.IsVacancyKnown,
|
||||
Title = entity.Title,
|
||||
Summary = entity.Summary,
|
||||
Source = FromJsonOr(entity.SourceJson, SourceRefs.Empty),
|
||||
Content = FromJsonOr(entity.ContentJson, new SourceContent { Text = entity.SourceText }),
|
||||
Stack = ToJsonList<string>(entity.StackJson),
|
||||
Budget = entity.BudgetCur.Length == 0
|
||||
? null
|
||||
: new CardBudgetDto(entity.BudgetFrom, entity.BudgetTo, entity.BudgetCur),
|
||||
Converted = entity.ConvCur.Length == 0
|
||||
? null
|
||||
: new CardBudgetDto(entity.ConvFrom, entity.ConvTo, entity.ConvCur),
|
||||
Contact = entity.Contact,
|
||||
Contacts = ToJsonList<CardContactDto>(entity.ContactsJson),
|
||||
Time = HumanAge(entity.ReceivedAt),
|
||||
ReceivedAtMs = entity.ReceivedAt.ToUnixTimeMilliseconds(),
|
||||
PrevCol = entity.PrevCol,
|
||||
MatchHits = ToJsonList<MatchHitDto>(entity.MatchHitsJson),
|
||||
Comments = comments,
|
||||
Links = ToJsonList<CardLinkDto>(entity.LinksJson),
|
||||
Files = ToJsonList<CardFileDto>(entity.FilesJson),
|
||||
History = ToJsonList<CardHistoryDto>(entity.HistoryJson),
|
||||
TzText = entity.TzText,
|
||||
Reminder = entity.ReminderAt is { } reminderAt ? new CardReminderDto(reminderAt.ToUnixTimeMilliseconds()) : null,
|
||||
CreatedAtMs = entity.CreatedAt.ToUnixTimeMilliseconds(),
|
||||
UpdatedAtMs = entity.UpdatedAt.ToUnixTimeMilliseconds(),
|
||||
};
|
||||
|
||||
// Разбирает одиночный JSON-объект; пустая/битая строка — запасное значение.
|
||||
private static T FromJsonOr<T>(string json, T fallback)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
{
|
||||
return fallback;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<T>(json, JsonOptions) ?? fallback;
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
// Маппинг снимка (write-модель создания карточки) в строку Cards; CreatedAt — UTC-now.
|
||||
private static CardEntity ToCardEntity(CardSnapshot snapshot) => new()
|
||||
{
|
||||
Id = snapshot.Id,
|
||||
Col = snapshot.Col,
|
||||
IsNew = snapshot.IsNew,
|
||||
Local = snapshot.Local,
|
||||
IsVacancy = snapshot.IsVacancy,
|
||||
IsVacancyKnown = snapshot.IsVacancyKnown,
|
||||
Title = snapshot.Title,
|
||||
Summary = snapshot.Summary,
|
||||
StackJson = ToJson(snapshot.Stack),
|
||||
BudgetFrom = snapshot.BudgetFrom,
|
||||
BudgetTo = snapshot.BudgetTo,
|
||||
BudgetCur = snapshot.BudgetCur,
|
||||
ConvFrom = snapshot.ConvFrom,
|
||||
ConvTo = snapshot.ConvTo,
|
||||
ConvCur = snapshot.ConvCur,
|
||||
Contact = snapshot.Contact,
|
||||
ContactsJson = ToJson(snapshot.Contacts),
|
||||
ReceivedAt = snapshot.ReceivedAt,
|
||||
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),
|
||||
TzText = snapshot.TzText,
|
||||
HistoryJson = ToJson(snapshot.History),
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
UpdatedAt = DateTimeOffset.UtcNow,
|
||||
};
|
||||
|
||||
private static CardCommentDto ToCommentDto(LeadCommentEntity entity) =>
|
||||
new(entity.Id, entity.By, entity.Text, HumanAge(entity.CreatedAt));
|
||||
|
||||
private static string HumanAge(DateTimeOffset timestamp)
|
||||
{
|
||||
long deltaMs = Math.Max(0, DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() - timestamp.ToUnixTimeMilliseconds());
|
||||
long minutes = deltaMs / MillisPerMinute;
|
||||
if (minutes < MinutesPerHour)
|
||||
{
|
||||
return minutes < 1 ? CardsService.JustNowLabel : $"{minutes} мин";
|
||||
}
|
||||
|
||||
long hours = minutes / MinutesPerHour;
|
||||
if (hours < HoursPerDay)
|
||||
{
|
||||
return $"{hours} ч";
|
||||
}
|
||||
|
||||
long days = hours / HoursPerDay;
|
||||
return $"{days} дн";
|
||||
}
|
||||
|
||||
private static IReadOnlyList<T> ToJsonList<T>(string json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
{
|
||||
return Array.Empty<T>();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<List<T>>(json, JsonOptions) ?? [];
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return Array.Empty<T>();
|
||||
}
|
||||
}
|
||||
|
||||
// Разбирает rules-объект: {}/пустая/битая строка — null («правил нет»).
|
||||
private static ContainerRulesDto? ToRulesOrNull(string json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json) || json == NoRulesJson)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<ContainerRulesDto>(json, JsonOptions);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Сериализует rules-объект: null («правил нет») хранится как {}.
|
||||
private static string ToRulesJson(ContainerRulesDto? rules) =>
|
||||
rules is null ? NoRulesJson : JsonSerializer.Serialize(rules, JsonOptions);
|
||||
|
||||
// Разбирает policy-объект: пустая/битая строка — политика по умолчанию (обычная колонка).
|
||||
private static ContainerPolicyDto ToPolicyOrEmpty(string json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json) || json == NoRulesJson)
|
||||
{
|
||||
return new ContainerPolicyDto();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<ContainerPolicyDto>(json, JsonOptions) ?? new ContainerPolicyDto();
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return new ContainerPolicyDto();
|
||||
}
|
||||
}
|
||||
|
||||
// Сериализует policy-объект в JSON (camelCase, конвенция value_json).
|
||||
private static string ToPolicyJson(ContainerPolicyDto policy) => JsonSerializer.Serialize(policy, JsonOptions);
|
||||
|
||||
// Сериализует значение в JSON (camelCase, конвенция value_json).
|
||||
private static string ToJson<T>(T value) => JsonSerializer.Serialize(value, JsonOptions);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
using Deal.Infrastructure.Persistence.Entities;
|
||||
using Deal.Modules.Kanban.Application.Abstractions;
|
||||
using Deal.Modules.Kanban.Application.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Deal.Infrastructure.Persistence.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// EF-адаптер хранилища обучения ML
|
||||
/// </summary>
|
||||
/// <param name="dbContext">Scoped-контекст тенанта запроса (search_path).</param>
|
||||
public sealed class MlLearningStore(TenantDbContext dbContext) : IMlLearningStore
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public Task<int> CountLearningAsync(CancellationToken ct) => dbContext.CardMoves.CountAsync(ct);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<int> CountOutboxAsync(CancellationToken ct) => dbContext.MlOutbox.CountAsync(ct);
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task AddOutboxAsync(
|
||||
string id,
|
||||
string text,
|
||||
string label,
|
||||
double delta,
|
||||
CancellationToken ct)
|
||||
{
|
||||
dbContext.MlOutbox.Add(new MlOutboxEntity
|
||||
{
|
||||
Id = id,
|
||||
Text = text,
|
||||
Label = label,
|
||||
Delta = delta,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
});
|
||||
await dbContext.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task ClearOutboxAsync(CancellationToken ct) => dbContext.MlOutbox.ExecuteDeleteAsync(ct);
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<MlOutboxEntryDto>> TakeOutboxBatchAsync(int limit, CancellationToken ct)
|
||||
{
|
||||
return await dbContext.MlOutbox
|
||||
.OrderBy(row => row.CreatedAt)
|
||||
.Take(limit)
|
||||
.Select(row => new MlOutboxEntryDto(row.Id, row.Text, row.Label, row.Delta))
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task DeleteOutboxAsync(IReadOnlyCollection<string> ids, CancellationToken ct)
|
||||
{
|
||||
if (ids.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await dbContext.MlOutbox
|
||||
.Where(row => ids.Contains(row.Id))
|
||||
.ExecuteDeleteAsync(ct);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
using Deal.Infrastructure.Persistence.Entities;
|
||||
using Deal.Modules.Tenants.Application.Abstractions;
|
||||
using Deal.Modules.Tenants.Application.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Deal.Infrastructure.Persistence.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// EF-адаптер хранилища аутентификации оператора
|
||||
/// </summary>
|
||||
public sealed class OperatorAuthStore(DealDbContext dbContext) : IOperatorAuthStore
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<StoredOperatorDto?> FindByLoginAsync(string login, CancellationToken ct)
|
||||
{
|
||||
var entity = await dbContext.Operators
|
||||
.AsNoTracking()
|
||||
.SingleOrDefaultAsync(o => o.Login == login, ct);
|
||||
return entity is null ? null : ToStoredOperatorDto(entity);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task CreateAsync(StoredOperatorDto operatorRecord, CancellationToken ct)
|
||||
{
|
||||
dbContext.Operators.Add(new OperatorEntity
|
||||
{
|
||||
Id = operatorRecord.Id,
|
||||
Login = operatorRecord.Login,
|
||||
Status = operatorRecord.Status,
|
||||
PasswordHash = operatorRecord.PasswordHash,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
});
|
||||
await dbContext.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<OperatorSessionDto?> FindSessionByTokenHashAsync(string tokenHash, CancellationToken ct)
|
||||
{
|
||||
// Протухшие сессии не возвращаем: с момента истечения они «не существуют»
|
||||
// (физическую очистку выполняет DeleteExpiredSessionsAsync).
|
||||
var entity = await dbContext.OperatorSessions
|
||||
.AsNoTracking()
|
||||
.SingleOrDefaultAsync(s => s.TokenHash == tokenHash && s.ExpiresAt > DateTimeOffset.UtcNow, ct);
|
||||
return entity is null ? null : ToOperatorSessionDto(entity);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task CreateSessionAsync(OperatorSessionDto session, CancellationToken ct)
|
||||
{
|
||||
dbContext.OperatorSessions.Add(new OperatorSessionEntity
|
||||
{
|
||||
TokenHash = session.TokenHash,
|
||||
OperatorId = session.OperatorId,
|
||||
Login = session.Login,
|
||||
ExpiresAt = session.ExpiresAt,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
});
|
||||
await dbContext.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task DeleteSessionAsync(string tokenHash, CancellationToken ct)
|
||||
{
|
||||
await dbContext.OperatorSessions
|
||||
.Where(s => s.TokenHash == tokenHash)
|
||||
.ExecuteDeleteAsync(ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task DeleteExpiredSessionsAsync(CancellationToken ct)
|
||||
{
|
||||
await dbContext.OperatorSessions
|
||||
.Where(s => s.ExpiresAt <= DateTimeOffset.UtcNow)
|
||||
.ExecuteDeleteAsync(ct);
|
||||
}
|
||||
|
||||
private static StoredOperatorDto ToStoredOperatorDto(OperatorEntity entity) =>
|
||||
new(entity.Id, entity.Login, entity.Status, entity.PasswordHash);
|
||||
|
||||
private static OperatorSessionDto ToOperatorSessionDto(OperatorSessionEntity entity) =>
|
||||
new(entity.TokenHash, entity.OperatorId, entity.Login, entity.ExpiresAt);
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
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;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Deal.Infrastructure.Persistence.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// EF-адаптер хранилища пайплайна
|
||||
/// </summary>
|
||||
public sealed class PipelineStore(TenantDbContext dbContext) : IPipelineStore
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
PropertyNameCaseInsensitive = true,
|
||||
};
|
||||
|
||||
// ── Очередь (QueueItems) ───────────────────────────────────────
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<bool> ExistsDuplicateAsync(
|
||||
SourceRef source,
|
||||
CancellationToken ct)
|
||||
{
|
||||
string key = source.DedupeKey();
|
||||
return dbContext.QueueItems.AnyAsync(item => item.SourceKey == key, ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task AddAsync(QueueItemDto item, CancellationToken ct)
|
||||
{
|
||||
dbContext.QueueItems.Add(ToQueueItemEntity(item));
|
||||
await dbContext.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<QueueItemDto>> ListAsync(
|
||||
string? status,
|
||||
int limit,
|
||||
CancellationToken ct)
|
||||
{
|
||||
IQueryable<QueueItemEntity> queryable = dbContext.QueueItems.AsNoTracking();
|
||||
if (status is not null)
|
||||
{
|
||||
queryable = queryable.Where(item => item.Status == status);
|
||||
}
|
||||
|
||||
List<QueueItemEntity> entities = await queryable
|
||||
.OrderBy(item => item.CreatedAt)
|
||||
.Take(limit)
|
||||
.ToListAsync(ct);
|
||||
return entities.Select(ToQueueItemDto).ToList();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<int> CountByStatusAsync(string status, CancellationToken ct)
|
||||
=> dbContext.QueueItems.CountAsync(item => item.Status == status, ct);
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task SetStatusAsync(
|
||||
string id,
|
||||
string status,
|
||||
CancellationToken ct)
|
||||
{
|
||||
// Смена статуса строки очереди (new → filtered в воркере): UpdatedAt = UTC-now (порт).
|
||||
await dbContext.QueueItems
|
||||
.Where(item => item.Id == id)
|
||||
.ExecuteUpdateAsync(setters => setters
|
||||
.SetProperty(item => item.Status, status)
|
||||
.SetProperty(item => item.UpdatedAt, DateTimeOffset.UtcNow),
|
||||
ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task RemoveAsync(string id, CancellationToken ct)
|
||||
{
|
||||
await dbContext.QueueItems
|
||||
.Where(item => item.Id == id)
|
||||
.ExecuteDeleteAsync(ct);
|
||||
}
|
||||
|
||||
// ── Отсев (RejectedItems) ──────────────────────────────────────────────
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task UpsertAsync(RejectRecord record, CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(record.Text))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
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", "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",
|
||||
"Stage" = excluded."Stage",
|
||||
"Reason" = excluded."Reason",
|
||||
"Kw" = excluded."Kw",
|
||||
"Source" = excluded."Source",
|
||||
"MsgAt" = excluded."MsgAt",
|
||||
"RejectedAt" = excluded."RejectedAt"
|
||||
""",
|
||||
ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<RejectedItemDto>> ListPageAsync(
|
||||
int offset,
|
||||
int limit,
|
||||
CancellationToken ct)
|
||||
{
|
||||
List<RejectedItemEntity> entities = await dbContext.RejectedItems
|
||||
.AsNoTracking()
|
||||
.OrderByDescending(item => item.RejectedAt)
|
||||
.Skip(offset)
|
||||
.Take(limit)
|
||||
.ToListAsync(ct);
|
||||
return entities.Select(ToRejectedItemDto).ToList();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<RejectedItemDto>> SearchAsync(
|
||||
string q,
|
||||
int limitFts,
|
||||
int limitLike,
|
||||
CancellationToken ct)
|
||||
{
|
||||
string query = q.Trim();
|
||||
if (query.Length == 0)
|
||||
{
|
||||
return Array.Empty<RejectedItemDto>();
|
||||
}
|
||||
|
||||
List<RejectedItemEntity> fts = await dbContext.RejectedItems
|
||||
.FromSqlInterpolated(
|
||||
$"""
|
||||
SELECT * FROM "RejectedItems"
|
||||
WHERE "SearchTsv" @@ plainto_tsquery('russian', {query})
|
||||
ORDER BY ts_rank("SearchTsv", plainto_tsquery('russian', {query})) DESC, "RejectedAt" DESC
|
||||
LIMIT {limitFts}
|
||||
""")
|
||||
.AsNoTracking()
|
||||
.ToListAsync(ct);
|
||||
|
||||
var rows = fts.Select(ToRejectedItemDto).ToList();
|
||||
var seen = new HashSet<string>(fts.Select(item => item.Id), StringComparer.Ordinal);
|
||||
|
||||
string pattern = $"%{query}%";
|
||||
List<RejectedItemEntity> like = await dbContext.RejectedItems
|
||||
.AsNoTracking()
|
||||
.Where(item => EF.Functions.Like(item.Text.ToLower(), pattern)
|
||||
|| EF.Functions.Like(item.Reason.ToLower(), pattern)
|
||||
|| EF.Functions.Like(item.Kw.ToLower(), pattern))
|
||||
.OrderByDescending(item => item.RejectedAt)
|
||||
.Take(limitLike)
|
||||
.ToListAsync(ct);
|
||||
|
||||
foreach (RejectedItemEntity entity in like)
|
||||
{
|
||||
if (seen.Add(entity.Id))
|
||||
{
|
||||
rows.Add(ToRejectedItemDto(entity));
|
||||
}
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<int> CountAsync(CancellationToken ct) => dbContext.RejectedItems.CountAsync(ct);
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<RejectedItemDto?> GetAsync(string id, CancellationToken ct)
|
||||
{
|
||||
RejectedItemEntity? entity = await dbContext.RejectedItems
|
||||
.AsNoTracking()
|
||||
.SingleOrDefaultAsync(item => item.Id == id, ct);
|
||||
return entity is null ? null : ToRejectedItemDto(entity);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task DeleteAsync(string id, CancellationToken ct)
|
||||
{
|
||||
await dbContext.RejectedItems
|
||||
.Where(item => item.Id == id)
|
||||
.ExecuteDeleteAsync(ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<int> ClearAsync(CancellationToken ct)
|
||||
{
|
||||
return await dbContext.RejectedItems.ExecuteDeleteAsync(ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<int> PurgeExpiredAsync(DateTimeOffset olderThan, CancellationToken ct)
|
||||
{
|
||||
return await dbContext.RejectedItems
|
||||
.Where(item => item.RejectedAt < olderThan)
|
||||
.ExecuteDeleteAsync(ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task MarkReturnedAsync(
|
||||
string id,
|
||||
string reason,
|
||||
DateTimeOffset returnedAt,
|
||||
CancellationToken ct)
|
||||
{
|
||||
await dbContext.RejectedItems
|
||||
.Where(item => item.Id == id)
|
||||
.ExecuteUpdateAsync(setters => setters
|
||||
.SetProperty(item => item.Returned, true)
|
||||
.SetProperty(item => item.ReturnedAt, returnedAt)
|
||||
.SetProperty(item => item.ReturnReason, reason),
|
||||
ct);
|
||||
}
|
||||
|
||||
// ── Дедуп (DedupEntries) ──────────────────────────────────────────────
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<bool> ExistsAsync(string hash, CancellationToken ct)
|
||||
=> dbContext.DedupEntries.AnyAsync(entry => entry.Hash == hash, ct);
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> ClaimAsync(string hash, CancellationToken ct)
|
||||
{
|
||||
int inserted = await dbContext.Database.ExecuteSqlInterpolatedAsync(
|
||||
$"""
|
||||
INSERT INTO "DedupEntries" ("Hash", "LeadId", "CreatedAt")
|
||||
VALUES ({hash}, NULL, {DateTimeOffset.UtcNow})
|
||||
ON CONFLICT ("Hash") DO NOTHING
|
||||
""",
|
||||
ct);
|
||||
return inserted == 1;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task DeleteClaimAsync(string hash, CancellationToken ct)
|
||||
{
|
||||
await dbContext.DedupEntries
|
||||
.Where(entry => entry.Hash == hash && entry.LeadId == null)
|
||||
.ExecuteDeleteAsync(ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task LinkAsync(
|
||||
string hash,
|
||||
string cardId,
|
||||
CancellationToken ct)
|
||||
{
|
||||
await dbContext.DedupEntries
|
||||
.Where(entry => entry.Hash == hash)
|
||||
.ExecuteUpdateAsync(setters => setters.SetProperty(entry => entry.LeadId, cardId), ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task DeleteByCardAsync(string cardId, CancellationToken ct)
|
||||
{
|
||||
await dbContext.DedupEntries
|
||||
.Where(entry => entry.LeadId == cardId)
|
||||
.ExecuteDeleteAsync(ct);
|
||||
}
|
||||
|
||||
// ── Маппинг (вручную: порт не видит EF-сущности) ───────────────────────
|
||||
|
||||
private static QueueItemDto ToQueueItemDto(QueueItemEntity entity) => new()
|
||||
{
|
||||
Id = entity.Id,
|
||||
Source = FromJsonOr(entity.SourceJson, SourceRefs.Empty),
|
||||
Content = FromJsonOr(entity.ContentJson, new SourceContent()),
|
||||
Text = entity.Text,
|
||||
Status = entity.Status,
|
||||
MsgAtMs = entity.MsgAt.ToUnixTimeMilliseconds(),
|
||||
QueuedAtMs = entity.CreatedAt.ToUnixTimeMilliseconds(),
|
||||
Force = entity.Force,
|
||||
};
|
||||
|
||||
// DTO очереди → строка QueueItems (CreatedAt = UpdatedAt = QueuedAtMs, задаёт модуль).
|
||||
private static QueueItemEntity ToQueueItemEntity(QueueItemDto item)
|
||||
{
|
||||
DateTimeOffset queuedAt = FromEpochMs(item.QueuedAtMs);
|
||||
return new QueueItemEntity
|
||||
{
|
||||
Id = item.Id,
|
||||
SourceKey = item.Source.DedupeKey(),
|
||||
SourceJson = ToJson(item.Source),
|
||||
ContentJson = ToJson(item.Content),
|
||||
Text = item.Text,
|
||||
Status = item.Status,
|
||||
MsgAt = FromEpochMs(item.MsgAtMs),
|
||||
CreatedAt = queuedAt,
|
||||
UpdatedAt = queuedAt,
|
||||
Force = item.Force,
|
||||
};
|
||||
}
|
||||
|
||||
private static RejectedItemDto ToRejectedItemDto(RejectedItemEntity entity) => new()
|
||||
{
|
||||
Id = entity.Id,
|
||||
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,
|
||||
DecidedBy = entity.Source,
|
||||
DecidedByLabel = PipelineRejectConstants.SourceLabel(entity.Source),
|
||||
MsgAtMs = entity.MsgAt.ToUnixTimeMilliseconds(),
|
||||
RejectedAtMs = entity.RejectedAt.ToUnixTimeMilliseconds(),
|
||||
Returned = entity.Returned,
|
||||
ReturnedAtMs = entity.ReturnedAt?.ToUnixTimeMilliseconds(),
|
||||
ReturnReason = entity.ReturnReason,
|
||||
};
|
||||
|
||||
// epoch-ms → DateTimeOffset (UTC-момент для timestamptz-колонки).
|
||||
private static DateTimeOffset FromEpochMs(long epochMs) => DateTimeOffset.FromUnixTimeMilliseconds(epochMs);
|
||||
|
||||
// Сериализует значение в JSON (camelCase, конвенция value_json).
|
||||
private static string ToJson<T>(T value) => JsonSerializer.Serialize(value, JsonOptions);
|
||||
|
||||
// Разбирает одиночный JSON-объект; пустая/битая строка — запасное значение.
|
||||
private static T FromJsonOr<T>(string json, T fallback)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
{
|
||||
return fallback;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<T>(json, JsonOptions) ?? fallback;
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
using System.Data;
|
||||
using System.Data.Common;
|
||||
using System.Globalization;
|
||||
using Deal.Infrastructure.Persistence.Entities;
|
||||
using Deal.Modules.Tenants.Application.Abstractions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Deal.Infrastructure.Persistence.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// EF-адаптер распределённого счётчика фиксированного окна
|
||||
/// </summary>
|
||||
public sealed class RateLimitCounterStore(DealDbContext dbContext) : IRateLimitCounterStore
|
||||
{
|
||||
// Атомарный upsert счётчика окна на Postgres (INSERT … ON CONFLICT … RETURNING).
|
||||
// Выполняется raw-командой (а не SqlQuery): EF запрещает композицию по не-SELECT SQL, а RETURNING
|
||||
// из INSERT не является SELECT-запросом. Одиночный INSERT … ON CONFLICT атомарен на уровне БД —
|
||||
// параллельные инкременты одного ключа не теряются.
|
||||
private const string NpgsqlIncrementSql = """
|
||||
INSERT INTO public.rate_limit_counters ("Key", "WindowStart", "ExpiresAt", "Count")
|
||||
VALUES (@key, @windowStart, @windowEnd, @amount)
|
||||
ON CONFLICT ("Key") DO UPDATE
|
||||
SET "Count" = CASE
|
||||
WHEN public.rate_limit_counters."WindowStart" = EXCLUDED."WindowStart"
|
||||
THEN public.rate_limit_counters."Count" + EXCLUDED."Count"
|
||||
ELSE EXCLUDED."Count"
|
||||
END,
|
||||
"WindowStart" = EXCLUDED."WindowStart",
|
||||
"ExpiresAt" = EXCLUDED."ExpiresAt"
|
||||
RETURNING "Count";
|
||||
""";
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<int> IncrementAsync(
|
||||
string key,
|
||||
DateTimeOffset windowStart,
|
||||
DateTimeOffset windowEnd,
|
||||
int amount,
|
||||
CancellationToken ct)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(key);
|
||||
ArgumentOutOfRangeException.ThrowIfNegative(amount);
|
||||
|
||||
if (dbContext.Database.IsNpgsql())
|
||||
{
|
||||
return await IncrementNpgsqlAsync(key, windowStart, windowEnd, amount, ct);
|
||||
}
|
||||
|
||||
// InMemory-провайдер (тесты): тот же результат read-modify-write отслеживаемой строки.
|
||||
RateLimitCounterEntity? entry = await dbContext.RateLimitCounters
|
||||
.SingleOrDefaultAsync(x => x.Key == key, ct);
|
||||
if (entry is null)
|
||||
{
|
||||
entry = new RateLimitCounterEntity
|
||||
{
|
||||
Key = key,
|
||||
WindowStart = windowStart,
|
||||
ExpiresAt = windowEnd,
|
||||
Count = amount,
|
||||
};
|
||||
dbContext.RateLimitCounters.Add(entry);
|
||||
}
|
||||
else if (entry.WindowStart == windowStart)
|
||||
{
|
||||
entry.Count += amount;
|
||||
entry.ExpiresAt = windowEnd;
|
||||
}
|
||||
else
|
||||
{
|
||||
entry.WindowStart = windowStart;
|
||||
entry.ExpiresAt = windowEnd;
|
||||
entry.Count = amount;
|
||||
}
|
||||
|
||||
await dbContext.SaveChangesAsync(ct);
|
||||
return entry.Count;
|
||||
}
|
||||
|
||||
// Атомарный инкремент окна raw-командой на Postgres (INSERT … ON CONFLICT … RETURNING).
|
||||
// key: Ключ счётчика.
|
||||
// windowStart: Начало окна (UTC).
|
||||
// windowEnd: Конец окна (UTC).
|
||||
// amount: Величина приращения.
|
||||
// ct: Токен отмены.
|
||||
// Возвращает: Значение счётчика после операции.
|
||||
private async Task<int> IncrementNpgsqlAsync(
|
||||
string key,
|
||||
DateTimeOffset windowStart,
|
||||
DateTimeOffset windowEnd,
|
||||
int amount,
|
||||
CancellationToken ct)
|
||||
{
|
||||
DbConnection connection = dbContext.Database.GetDbConnection();
|
||||
bool openedHere = connection.State != ConnectionState.Open;
|
||||
if (openedHere)
|
||||
{
|
||||
await connection.OpenAsync(ct);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await using DbCommand command = connection.CreateCommand();
|
||||
command.CommandText = NpgsqlIncrementSql;
|
||||
AddParameter(command, "@key", key);
|
||||
AddParameter(command, "@windowStart", windowStart);
|
||||
AddParameter(command, "@windowEnd", windowEnd);
|
||||
AddParameter(command, "@amount", amount);
|
||||
object? result = await command.ExecuteScalarAsync(ct);
|
||||
return Convert.ToInt32(result, CultureInfo.InvariantCulture);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (openedHere)
|
||||
{
|
||||
await connection.CloseAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Добавляет параметр команды (провайдер-независимо, через DbParameter).
|
||||
// command: Команда upsert.
|
||||
// name: Имя параметра (с префиксом @).
|
||||
// value: Значение.
|
||||
private static void AddParameter(
|
||||
DbCommand command,
|
||||
string name,
|
||||
object value)
|
||||
{
|
||||
DbParameter parameter = command.CreateParameter();
|
||||
parameter.ParameterName = name;
|
||||
parameter.Value = value;
|
||||
command.Parameters.Add(parameter);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<int> GetCountAsync(
|
||||
string key,
|
||||
DateTimeOffset windowStart,
|
||||
CancellationToken ct)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(key);
|
||||
|
||||
if (dbContext.Database.IsNpgsql())
|
||||
{
|
||||
return await dbContext.Database
|
||||
.SqlQuery<int>($"""
|
||||
SELECT "Count" AS "Value" FROM public.rate_limit_counters
|
||||
WHERE "Key" = {key} AND "WindowStart" = {windowStart}
|
||||
""")
|
||||
.SingleOrDefaultAsync(ct);
|
||||
}
|
||||
|
||||
RateLimitCounterEntity? entry = await dbContext.RateLimitCounters
|
||||
.AsNoTracking()
|
||||
.SingleOrDefaultAsync(x => x.Key == key, ct);
|
||||
return entry is not null && entry.WindowStart == windowStart ? entry.Count : 0;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task ResetAsync(string key, CancellationToken ct)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(key);
|
||||
|
||||
if (dbContext.Database.IsNpgsql())
|
||||
{
|
||||
await dbContext.RateLimitCounters.Where(x => x.Key == key).ExecuteDeleteAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
RateLimitCounterEntity? entry = await dbContext.RateLimitCounters
|
||||
.SingleOrDefaultAsync(x => x.Key == key, ct);
|
||||
if (entry is not null)
|
||||
{
|
||||
dbContext.RateLimitCounters.Remove(entry);
|
||||
await dbContext.SaveChangesAsync(ct);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<int> DeleteExpiredAsync(DateTimeOffset now, CancellationToken ct)
|
||||
{
|
||||
if (dbContext.Database.IsNpgsql())
|
||||
{
|
||||
return await dbContext.RateLimitCounters.Where(x => x.ExpiresAt < now).ExecuteDeleteAsync(ct);
|
||||
}
|
||||
|
||||
List<RateLimitCounterEntity> expired = await dbContext.RateLimitCounters
|
||||
.Where(x => x.ExpiresAt < now)
|
||||
.ToListAsync(ct);
|
||||
if (expired.Count == 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
dbContext.RateLimitCounters.RemoveRange(expired);
|
||||
await dbContext.SaveChangesAsync(ct);
|
||||
return expired.Count;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
using Deal.Infrastructure.Persistence.Entities;
|
||||
using Deal.Modules.Settings.Application.Abstractions;
|
||||
using Deal.Modules.Settings.Application.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Deal.Infrastructure.Persistence.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// EF-адаптер KV-хранилища настроек тенанта
|
||||
/// </summary>
|
||||
public sealed class SettingsStore(TenantDbContext dbContext) : ISettingsStore
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<SettingValue?> GetAsync(string key, CancellationToken ct)
|
||||
{
|
||||
var entity = await dbContext.Settings
|
||||
.AsNoTracking()
|
||||
.SingleOrDefaultAsync(s => s.Key == key, ct);
|
||||
return entity is null ? null : ToSettingValue(entity);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyCollection<SettingValue>> GetAllAsync(CancellationToken ct)
|
||||
{
|
||||
var entities = await dbContext.Settings
|
||||
.AsNoTracking()
|
||||
.OrderBy(s => s.Key)
|
||||
.Select(s => ToSettingValue(s))
|
||||
.ToListAsync(ct);
|
||||
return entities;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task SetAsync(
|
||||
string key,
|
||||
string valueJson,
|
||||
CancellationToken ct)
|
||||
{
|
||||
// Upsert по ключу (PK): существующая строка обновляется, отсутствующая — добавляется.
|
||||
TenantSettingEntity? entity = await dbContext.Settings
|
||||
.SingleOrDefaultAsync(s => s.Key == key, ct);
|
||||
if (entity is null)
|
||||
{
|
||||
dbContext.Settings.Add(new TenantSettingEntity
|
||||
{
|
||||
Key = key,
|
||||
ValueJson = valueJson,
|
||||
UpdatedAt = DateTimeOffset.UtcNow,
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
entity.ValueJson = valueJson;
|
||||
entity.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
await dbContext.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task RemoveAsync(string key, CancellationToken ct)
|
||||
{
|
||||
// ExecuteDelete удаляет существующую строку; при отсутствии ключа — no-op.
|
||||
await dbContext.Settings
|
||||
.Where(s => s.Key == key)
|
||||
.ExecuteDeleteAsync(ct);
|
||||
}
|
||||
|
||||
private static SettingValue ToSettingValue(TenantSettingEntity entity) =>
|
||||
new(entity.Key, entity.ValueJson, entity.UpdatedAt);
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
using Deal.Contracts.Integrations.Models;
|
||||
using Deal.Infrastructure.Persistence.Entities;
|
||||
using Deal.Modules.Telegram.Application;
|
||||
using Deal.Modules.Telegram.Application.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Deal.Infrastructure.Persistence.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// EF-адаптер хранилища каталога диалогов
|
||||
/// </summary>
|
||||
/// <param name="dbContext">Scoped-контекст тенанта запроса (search_path).</param>
|
||||
public sealed class TelegramStore(TenantDbContext dbContext) : ITelegramStore
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<int> SyncFromTelegramAsync(
|
||||
IReadOnlyCollection<TelegramDialogEntryDto> entries,
|
||||
bool autoMonitorNew,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (entries.Count == 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
string[] ids = entries.Select(entry => entry.Id).ToArray();
|
||||
Dictionary<string, DialogEntity> existing = await dbContext.Dialogs
|
||||
.Where(dialog => ids.Contains(dialog.Id))
|
||||
.ToDictionaryAsync(dialog => dialog.Id, ct);
|
||||
|
||||
DateTimeOffset now = DateTimeOffset.UtcNow;
|
||||
foreach (TelegramDialogEntryDto entry in entries)
|
||||
{
|
||||
if (existing.TryGetValue(entry.Id, out DialogEntity? row))
|
||||
{
|
||||
row.Name = entry.Name;
|
||||
row.Handle = entry.Handle;
|
||||
row.Kind = entry.Kind;
|
||||
row.Hue = entry.Hue;
|
||||
row.UpdatedAt = now;
|
||||
}
|
||||
else
|
||||
{
|
||||
dbContext.Dialogs.Add(new DialogEntity
|
||||
{
|
||||
Id = entry.Id,
|
||||
Name = entry.Name,
|
||||
Handle = entry.Handle,
|
||||
Kind = entry.Kind,
|
||||
Hue = entry.Hue,
|
||||
Monitor = autoMonitorNew,
|
||||
UpdatedAt = now,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
List<DialogEntity> stale = await dbContext.Dialogs
|
||||
.Where(dialog => !ids.Contains(dialog.Id))
|
||||
.ToListAsync(ct);
|
||||
dbContext.Dialogs.RemoveRange(stale);
|
||||
|
||||
await dbContext.SaveChangesAsync(ct);
|
||||
return entries.Count;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<TelegramDialogDto>> ListAsync(CancellationToken ct)
|
||||
{
|
||||
List<DialogEntity> rows = await dbContext.Dialogs
|
||||
.OrderByDescending(dialog => dialog.Monitor)
|
||||
.ThenBy(dialog => dialog.Name)
|
||||
.ToListAsync(ct);
|
||||
return rows.Select(ToDialogDto).ToList();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyCollection<string>> ListMonitoredIdsAsync(CancellationToken ct)
|
||||
{
|
||||
return await dbContext.Dialogs
|
||||
.Where(dialog => dialog.Monitor)
|
||||
.OrderBy(dialog => dialog.Id)
|
||||
.Select(dialog => dialog.Id)
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task SetMonitorAsync(
|
||||
string dialogId,
|
||||
bool enabled,
|
||||
CancellationToken ct)
|
||||
{
|
||||
return dbContext.Dialogs
|
||||
.Where(dialog => dialog.Id == dialogId)
|
||||
.ExecuteUpdateAsync(
|
||||
setters => setters
|
||||
.SetProperty(dialog => dialog.Monitor, enabled)
|
||||
.SetProperty(dialog => dialog.UpdatedAt, DateTimeOffset.UtcNow),
|
||||
ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<int> SetMonitorAllAsync(bool enabled, CancellationToken ct)
|
||||
{
|
||||
int count = await dbContext.Dialogs.CountAsync(ct);
|
||||
await dbContext.Dialogs.ExecuteUpdateAsync(
|
||||
setters => setters
|
||||
.SetProperty(dialog => dialog.Monitor, enabled)
|
||||
.SetProperty(dialog => dialog.UpdatedAt, DateTimeOffset.UtcNow),
|
||||
ct);
|
||||
return count;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool?> GetBackfilledAsync(string dialogId, CancellationToken ct)
|
||||
{
|
||||
return await dbContext.Dialogs
|
||||
.Where(dialog => dialog.Id == dialogId)
|
||||
.Select(dialog => (bool?)dialog.Backfilled)
|
||||
.FirstOrDefaultAsync(ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyCollection<string>> ListNotBackfilledIdsAsync(CancellationToken ct)
|
||||
{
|
||||
return await dbContext.Dialogs
|
||||
.Where(dialog => !dialog.Backfilled)
|
||||
.OrderBy(dialog => dialog.Id)
|
||||
.Select(dialog => dialog.Id)
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task SetBackfilledAsync(string dialogId, CancellationToken ct)
|
||||
{
|
||||
return dbContext.Dialogs
|
||||
.Where(dialog => dialog.Id == dialogId)
|
||||
.ExecuteUpdateAsync(setters => setters.SetProperty(dialog => dialog.Backfilled, true), ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task UpsertDiscoveredMonitoredAsync(
|
||||
string dialogId,
|
||||
string name,
|
||||
string handle,
|
||||
string kind,
|
||||
string hue,
|
||||
CancellationToken ct)
|
||||
{
|
||||
DialogEntity? existing = await dbContext.Dialogs.FirstOrDefaultAsync(dialog => dialog.Id == dialogId, ct);
|
||||
DateTimeOffset now = DateTimeOffset.UtcNow;
|
||||
if (existing is null)
|
||||
{
|
||||
dbContext.Dialogs.Add(new DialogEntity
|
||||
{
|
||||
Id = dialogId,
|
||||
Name = name,
|
||||
Handle = handle,
|
||||
Kind = kind,
|
||||
Hue = hue,
|
||||
Monitor = true,
|
||||
Backfilled = false,
|
||||
UpdatedAt = now,
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
existing.Name = name;
|
||||
existing.Handle = handle;
|
||||
existing.Kind = kind;
|
||||
existing.Hue = hue;
|
||||
existing.Monitor = true;
|
||||
existing.Backfilled = false;
|
||||
existing.UpdatedAt = now;
|
||||
}
|
||||
|
||||
await dbContext.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task SavePreviewAsync(
|
||||
string messageId,
|
||||
string dialogId,
|
||||
string text,
|
||||
DateTimeOffset msgAt,
|
||||
CancellationToken ct)
|
||||
{
|
||||
bool exists = await dbContext.TgMessages.AnyAsync(message => message.Id == messageId, ct);
|
||||
if (exists)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
dbContext.TgMessages.Add(new TgMessageEntity
|
||||
{
|
||||
Id = messageId,
|
||||
DialogId = dialogId,
|
||||
Text = text,
|
||||
MsgAt = msgAt,
|
||||
});
|
||||
await dbContext.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task TouchDialogLastAsync(
|
||||
string dialogId,
|
||||
string text,
|
||||
DateTimeOffset at,
|
||||
CancellationToken ct)
|
||||
{
|
||||
return dbContext.Dialogs
|
||||
.Where(dialog => dialog.Id == dialogId)
|
||||
.ExecuteUpdateAsync(
|
||||
setters => setters
|
||||
.SetProperty(dialog => dialog.LastText, text)
|
||||
.SetProperty(dialog => dialog.LastAt, at)
|
||||
.SetProperty(dialog => dialog.UpdatedAt, at),
|
||||
ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<TelegramMessageDto>> ListMessagesAsync(
|
||||
string dialogId,
|
||||
int limit,
|
||||
CancellationToken ct)
|
||||
{
|
||||
List<TgMessageEntity> rows = await dbContext.TgMessages
|
||||
.Where(message => message.DialogId == dialogId)
|
||||
.OrderByDescending(message => message.MsgAt)
|
||||
.Take(limit)
|
||||
.ToListAsync(ct);
|
||||
return rows.Select(row => new TelegramMessageDto(
|
||||
row.Id, row.Text, row.MsgAt.ToUnixTimeMilliseconds(), row.LeadId is not null)).ToList();
|
||||
}
|
||||
|
||||
private static TelegramDialogDto ToDialogDto(DialogEntity row)
|
||||
{
|
||||
long? lastAtMs = row.LastAt?.ToUnixTimeMilliseconds();
|
||||
TelegramDialogLastDto? last = lastAtMs is null ? null : new TelegramDialogLastDto(row.LastText, lastAtMs);
|
||||
return new TelegramDialogDto(row.Id, row.Name, row.Handle, row.Kind, row.Hue, row.Monitor, last);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
using Deal.Infrastructure.Persistence.Entities;
|
||||
using Deal.Modules.Tenants.Application.Abstractions;
|
||||
using Deal.Modules.Tenants.Application.Models;
|
||||
using Deal.Modules.Tenants.Application.Services;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Deal.Infrastructure.Persistence.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// EF-адаптер хранилища лимитов ИИ-бюджета
|
||||
/// </summary>
|
||||
public sealed class TenantLimitStore : ITenantLimitStore
|
||||
{
|
||||
private readonly DealDbContext _dbContext;
|
||||
private readonly TokenLimitDefaults _defaults;
|
||||
private readonly TokenBudgetService _budgetService;
|
||||
private readonly Func<DateTimeOffset> _utcNow;
|
||||
|
||||
/// <summary>
|
||||
/// Создаёт адаптер с дефолт-бюджетом модуля
|
||||
/// </summary>
|
||||
/// <param name="dbContext">Системный контекст (public-схема).</param>
|
||||
public TenantLimitStore(DealDbContext dbContext)
|
||||
: this(dbContext, TokenBudgetDefaults.Default, new TokenBudgetService(), () => DateTimeOffset.UtcNow)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Создаёт адаптер с дефолт-бюджетом из конфигурации
|
||||
/// </summary>
|
||||
/// <param name="dbContext">Системный контекст (public-схема).</param>
|
||||
/// <param name="defaults">Дефолт-параметры лениво создаваемой строки.</param>
|
||||
public TenantLimitStore(DealDbContext dbContext, TokenLimitDefaults defaults)
|
||||
: this(dbContext, defaults, new TokenBudgetService(), () => DateTimeOffset.UtcNow)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Создаёт адаптер с явными зависимостями
|
||||
/// </summary>
|
||||
/// <param name="dbContext">Системный контекст (public-схема).</param>
|
||||
/// <param name="defaults">Дефолт-параметры лениво создаваемой строки.</param>
|
||||
/// <param name="budgetService">Период-математика (reset/пороги).</param>
|
||||
/// <param name="utcNow">Источник текущего времени (UTC).</param>
|
||||
public TenantLimitStore(
|
||||
DealDbContext dbContext,
|
||||
TokenLimitDefaults defaults,
|
||||
TokenBudgetService budgetService,
|
||||
Func<DateTimeOffset> utcNow)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(dbContext);
|
||||
ArgumentNullException.ThrowIfNull(defaults);
|
||||
ArgumentNullException.ThrowIfNull(budgetService);
|
||||
ArgumentNullException.ThrowIfNull(utcNow);
|
||||
_dbContext = dbContext;
|
||||
_defaults = defaults;
|
||||
_budgetService = budgetService;
|
||||
_utcNow = utcNow;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
async Task<TenantLimitDto> ITenantLimitStore.GetOrCreateAsync(
|
||||
Guid tenantId,
|
||||
CancellationToken ct,
|
||||
TokenLimitDefaults? defaults)
|
||||
{
|
||||
TenantLimitEntity entity = await LoadOrCreateAsync(tenantId, defaults ?? _defaults, ct);
|
||||
return ToLimitDto(entity);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
async Task<BudgetStateDto> ITenantLimitStore.GetStateAsync(Guid tenantId, CancellationToken ct)
|
||||
{
|
||||
TenantLimitEntity entity = await LoadOrCreateAsync(tenantId, _defaults, ct);
|
||||
await ResetIfPeriodExpiredAsync(entity, ct);
|
||||
return await ToStateDtoAsync(entity, ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
async Task<BudgetStateDto> ITenantLimitStore.AddUsageAsync(
|
||||
Guid tenantId,
|
||||
long tokens,
|
||||
CancellationToken ct)
|
||||
{
|
||||
TenantLimitEntity entity = await LoadOrCreateAsync(tenantId, _defaults, ct);
|
||||
await ResetIfPeriodExpiredAsync(entity, ct);
|
||||
if (tokens > 0)
|
||||
{
|
||||
if (_dbContext.Database.IsNpgsql())
|
||||
{
|
||||
// Атомарный инкремент одним UPDATE (Security review A12): параллельные списания одного тенанта
|
||||
// (пользовательские ИИ-вызовы) не теряют токены — read-modify-write ниже неатомарен под гонкой.
|
||||
var now = _utcNow();
|
||||
await _dbContext.Database.ExecuteSqlInterpolatedAsync(
|
||||
$"UPDATE public.tenant_limits SET \"UsedTokens\" = \"UsedTokens\" + {tokens}, \"UpdatedAt\" = {now} WHERE \"TenantId\" = {tenantId}",
|
||||
ct);
|
||||
// Синхронизировать отслеживаемую сущность с БД (ToStateDtoAsync читает её поля).
|
||||
await _dbContext.Entry(entity).ReloadAsync(ct);
|
||||
}
|
||||
else
|
||||
{
|
||||
// InMemory-провайдер (юнит-тесты) не исполняет raw SQL — EF read-modify-write (семантика та же).
|
||||
entity.UsedTokens += tokens;
|
||||
entity.UpdatedAt = _utcNow();
|
||||
await _dbContext.SaveChangesAsync(ct);
|
||||
}
|
||||
}
|
||||
|
||||
return await ToStateDtoAsync(entity, ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
async Task<BudgetStateDto> ITenantLimitStore.UpdateBudgetAsync(
|
||||
Guid tenantId,
|
||||
long budgetTokens,
|
||||
string period,
|
||||
CancellationToken ct)
|
||||
{
|
||||
ArgumentOutOfRangeException.ThrowIfNegative(budgetTokens);
|
||||
if (period != TenantLimitPeriods.Month && period != TenantLimitPeriods.Day)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(period), period, "Период лимита: month или day.");
|
||||
}
|
||||
|
||||
TenantLimitEntity entity = await LoadOrCreateAsync(tenantId, _defaults, ct);
|
||||
entity.BudgetTokens = budgetTokens;
|
||||
entity.Period = period;
|
||||
entity.Warned80 = false;
|
||||
entity.NotifiedExhausted = false;
|
||||
entity.UpdatedAt = _utcNow();
|
||||
await _dbContext.SaveChangesAsync(ct);
|
||||
return await ToStateDtoAsync(entity, ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
async Task<bool> ITenantLimitStore.TryMarkWarnedAsync(Guid tenantId, CancellationToken ct)
|
||||
{
|
||||
TenantLimitEntity entity = await LoadOrCreateAsync(tenantId, _defaults, ct);
|
||||
await ResetIfPeriodExpiredAsync(entity, ct);
|
||||
if (entity.Warned80 || !_budgetService.IsWarned80(entity.UsedTokens, entity.BudgetTokens))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
entity.Warned80 = true;
|
||||
entity.UpdatedAt = _utcNow();
|
||||
await _dbContext.SaveChangesAsync(ct);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
async Task<bool> ITenantLimitStore.TryMarkNotifiedExhaustedAsync(Guid tenantId, CancellationToken ct)
|
||||
{
|
||||
TenantLimitEntity entity = await LoadOrCreateAsync(tenantId, _defaults, ct);
|
||||
await ResetIfPeriodExpiredAsync(entity, ct);
|
||||
if (entity.NotifiedExhausted || !_budgetService.IsExhausted(entity.UsedTokens, entity.BudgetTokens))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
entity.NotifiedExhausted = true;
|
||||
entity.UpdatedAt = _utcNow();
|
||||
await _dbContext.SaveChangesAsync(ct);
|
||||
return true;
|
||||
}
|
||||
|
||||
private async Task<TenantLimitEntity> LoadOrCreateAsync(
|
||||
Guid tenantId,
|
||||
TokenLimitDefaults defaults,
|
||||
CancellationToken ct)
|
||||
{
|
||||
TenantLimitEntity? entity = await _dbContext.TenantLimits
|
||||
.SingleOrDefaultAsync(x => x.TenantId == tenantId, ct);
|
||||
if (entity is not null)
|
||||
{
|
||||
return entity;
|
||||
}
|
||||
|
||||
var created = new TenantLimitEntity
|
||||
{
|
||||
TenantId = tenantId,
|
||||
BudgetTokens = defaults.BudgetTokens,
|
||||
Period = defaults.Period,
|
||||
PeriodStart = _utcNow(),
|
||||
UsedTokens = 0,
|
||||
Warned80 = false,
|
||||
NotifiedExhausted = false,
|
||||
UpdatedAt = _utcNow(),
|
||||
};
|
||||
_dbContext.TenantLimits.Add(created);
|
||||
await _dbContext.SaveChangesAsync(ct);
|
||||
return created;
|
||||
}
|
||||
|
||||
private async Task ResetIfPeriodExpiredAsync(TenantLimitEntity entity, CancellationToken ct)
|
||||
{
|
||||
if (ResetIfExpired(entity, _utcNow()))
|
||||
{
|
||||
await _dbContext.SaveChangesAsync(ct);
|
||||
}
|
||||
}
|
||||
|
||||
// Обнуляет накопительные поля строки, если её период завершился к now.
|
||||
// entity: Отслеживаемая строка лимита.
|
||||
// now: Текущий момент (UTC).
|
||||
// Возвращает: True — период был сброшен (строка изменена).
|
||||
private bool ResetIfExpired(TenantLimitEntity entity, DateTimeOffset now)
|
||||
{
|
||||
if (!_budgetService.IsPeriodExpired(entity.PeriodStart, entity.Period, now))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
entity.UsedTokens = 0;
|
||||
entity.Warned80 = false;
|
||||
entity.NotifiedExhausted = false;
|
||||
entity.PeriodStart = now;
|
||||
entity.UpdatedAt = now;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
async Task<int> ITenantLimitStore.ResetExpiredPeriodsAsync(DateTimeOffset now, CancellationToken ct)
|
||||
{
|
||||
// Трогаем только строки с накоплениями (расход/флаги): строки без накоплений чистить нечего.
|
||||
List<TenantLimitEntity> candidates = await _dbContext.TenantLimits
|
||||
.Where(x => x.UsedTokens > 0 || x.Warned80 || x.NotifiedExhausted)
|
||||
.ToListAsync(ct);
|
||||
int reset = 0;
|
||||
foreach (TenantLimitEntity entity in candidates)
|
||||
{
|
||||
if (ResetIfExpired(entity, now))
|
||||
{
|
||||
reset++;
|
||||
}
|
||||
}
|
||||
|
||||
if (reset > 0)
|
||||
{
|
||||
await _dbContext.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
return reset;
|
||||
}
|
||||
|
||||
private async Task<BudgetStateDto> ToStateDtoAsync(TenantLimitEntity entity, CancellationToken ct)
|
||||
{
|
||||
string? tenantStatus = await _dbContext.Tenants
|
||||
.AsNoTracking()
|
||||
.Where(t => t.Id == entity.TenantId)
|
||||
.Select(t => t.Status)
|
||||
.SingleOrDefaultAsync(ct);
|
||||
string status = tenantStatus ?? TenantStatuses.Suspended;
|
||||
bool allowed = status == TenantStatuses.Active
|
||||
&& !_budgetService.IsExhausted(entity.UsedTokens, entity.BudgetTokens);
|
||||
return new BudgetStateDto(
|
||||
entity.TenantId,
|
||||
entity.BudgetTokens,
|
||||
entity.Period,
|
||||
entity.PeriodStart,
|
||||
entity.UsedTokens,
|
||||
status,
|
||||
allowed,
|
||||
entity.Warned80,
|
||||
entity.NotifiedExhausted);
|
||||
}
|
||||
|
||||
private static TenantLimitDto ToLimitDto(TenantLimitEntity entity) =>
|
||||
new(
|
||||
entity.TenantId,
|
||||
entity.BudgetTokens,
|
||||
entity.Period,
|
||||
entity.PeriodStart,
|
||||
entity.UsedTokens,
|
||||
entity.Warned80,
|
||||
entity.NotifiedExhausted);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
using Deal.Infrastructure.Persistence.Entities;
|
||||
using Deal.Modules.Tenants.Application.Abstractions;
|
||||
using Deal.Modules.Tenants.Application.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Deal.Infrastructure.Persistence.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// EF-адаптер реестра тенантов
|
||||
/// </summary>
|
||||
public sealed class TenantRepository(DealDbContext dbContext) : ITenantRepository
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<TenantRecordDto?> FindByIdAsync(Guid id, CancellationToken ct)
|
||||
{
|
||||
var entity = await dbContext.Tenants
|
||||
.AsNoTracking()
|
||||
.SingleOrDefaultAsync(t => t.Id == id, ct);
|
||||
return entity is null ? null : ToTenantRecordDto(entity);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task CreateAsync(TenantRecordDto tenant, CancellationToken ct)
|
||||
{
|
||||
dbContext.Tenants.Add(new TenantEntity
|
||||
{
|
||||
Id = tenant.Id,
|
||||
Name = tenant.Name,
|
||||
Status = tenant.Status,
|
||||
CreatedAt = tenant.CreatedAt,
|
||||
});
|
||||
await dbContext.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<TenantRecordDto>> ListAsync(CancellationToken ct)
|
||||
{
|
||||
var entities = await dbContext.Tenants
|
||||
.AsNoTracking()
|
||||
.OrderBy(t => t.CreatedAt)
|
||||
.ThenBy(t => t.Id)
|
||||
.Select(t => ToTenantRecordDto(t))
|
||||
.ToListAsync(ct);
|
||||
return entities;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<TenantRecordDto>> ListPageAsync(
|
||||
int offset,
|
||||
int limit,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var entities = await dbContext.Tenants
|
||||
.AsNoTracking()
|
||||
.OrderBy(t => t.CreatedAt)
|
||||
.ThenBy(t => t.Id)
|
||||
.Skip(offset)
|
||||
.Take(limit)
|
||||
.Select(t => ToTenantRecordDto(t))
|
||||
.ToListAsync(ct);
|
||||
return entities;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> UpdateStatusAsync(
|
||||
Guid id,
|
||||
string status,
|
||||
CancellationToken ct)
|
||||
{
|
||||
// Отслеживаемая запись + SaveChanges (не ExecuteUpdateAsync): операция редкая (операторская админка),
|
||||
// зато семантика проверяема на InMemory-провайдере в unit-тестах.
|
||||
var entity = await dbContext.Tenants.SingleOrDefaultAsync(t => t.Id == id, ct);
|
||||
if (entity is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
entity.Status = status;
|
||||
await dbContext.SaveChangesAsync(ct);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static TenantRecordDto ToTenantRecordDto(TenantEntity entity) =>
|
||||
new(entity.Id, entity.Name, entity.Status, entity.CreatedAt);
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
using System.Linq.Expressions;
|
||||
using Deal.Infrastructure.Persistence.Entities;
|
||||
using Deal.Modules.Tenants.Application.Abstractions;
|
||||
using Deal.Modules.Tenants.Application.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Deal.Infrastructure.Persistence.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// EF-адаптер истории расхода токенов
|
||||
/// </summary>
|
||||
public sealed class TokenUsageEventStore(DealDbContext dbContext) : ITokenUsageEventStore
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task AppendAsync(TokenUsageEventDto record, CancellationToken ct)
|
||||
{
|
||||
dbContext.TokenUsageEvents.Add(new TokenUsageEventEntity
|
||||
{
|
||||
// Id генерирует БД (identity) — из DTO не копируется.
|
||||
TenantId = record.TenantId,
|
||||
At = record.At,
|
||||
Provider = record.Provider,
|
||||
Model = record.Model,
|
||||
Kind = record.Kind,
|
||||
PromptTokens = record.PromptTokens,
|
||||
CompletionTokens = record.CompletionTokens,
|
||||
TotalTokens = record.TotalTokens,
|
||||
DetailJson = record.DetailJson,
|
||||
});
|
||||
await dbContext.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<TokenUsageAggregateDto>> AggregateAsync(TokenUsageEventQueryDto query, CancellationToken ct)
|
||||
{
|
||||
IQueryable<TokenUsageEventEntity> source = ApplyFilters(dbContext.TokenUsageEvents.AsNoTracking(), query);
|
||||
return query.GroupBy switch
|
||||
{
|
||||
TokenUsageGroupBys.Day => await AggregateByDayAsync(source, ct),
|
||||
TokenUsageGroupBys.Tenant => await AggregateByTenantAsync(source, ct),
|
||||
TokenUsageGroupBys.Provider => await AggregateByStringAsync(source, entity => entity.Provider, ct),
|
||||
TokenUsageGroupBys.Model => await AggregateByStringAsync(source, entity => entity.Model, ct),
|
||||
_ => throw new ArgumentException($"Неизвестная группировка расхода токенов: '{query.GroupBy}'.", nameof(query)),
|
||||
};
|
||||
}
|
||||
|
||||
// Применяет фильтры агрегации (TenantId/Provider/Model/Kind/At-range).
|
||||
// source: Базовый запрос.
|
||||
// query: Фильтр/группировка.
|
||||
// Возвращает: Запрос с фильтрами.
|
||||
private static IQueryable<TokenUsageEventEntity> ApplyFilters(IQueryable<TokenUsageEventEntity> source, TokenUsageEventQueryDto query)
|
||||
{
|
||||
if (query.TenantId is not null)
|
||||
{
|
||||
source = source.Where(e => e.TenantId == query.TenantId);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query.Provider))
|
||||
{
|
||||
source = source.Where(e => e.Provider == query.Provider);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query.Model))
|
||||
{
|
||||
source = source.Where(e => e.Model == query.Model);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query.Kind))
|
||||
{
|
||||
source = source.Where(e => e.Kind == query.Kind);
|
||||
}
|
||||
|
||||
if (query.From is not null)
|
||||
{
|
||||
source = source.Where(e => e.At >= query.From.Value);
|
||||
}
|
||||
|
||||
if (query.To is not null)
|
||||
{
|
||||
source = source.Where(e => e.At <= query.To.Value);
|
||||
}
|
||||
|
||||
return source;
|
||||
}
|
||||
|
||||
// Агрегат по суткам UTC (ключ ГГГГ-ММ-ДД), порядок — по возрастанию даты.
|
||||
// source: Отфильтрованный запрос.
|
||||
// ct: Токен отмены.
|
||||
// Возвращает: Строки агрегатов по дням.
|
||||
private static async Task<IReadOnlyList<TokenUsageAggregateDto>> AggregateByDayAsync(IQueryable<TokenUsageEventEntity> source, CancellationToken ct)
|
||||
{
|
||||
var rows = await source
|
||||
.GroupBy(e => new { e.At.Year, e.At.Month, e.At.Day })
|
||||
.Select(group => new
|
||||
{
|
||||
group.Key.Year,
|
||||
group.Key.Month,
|
||||
group.Key.Day,
|
||||
Prompt = group.Sum(x => x.PromptTokens),
|
||||
Completion = group.Sum(x => x.CompletionTokens),
|
||||
Total = group.Sum(x => x.TotalTokens),
|
||||
Count = group.LongCount(),
|
||||
})
|
||||
.ToListAsync(ct);
|
||||
|
||||
return rows
|
||||
.OrderBy(row => row.Year)
|
||||
.ThenBy(row => row.Month)
|
||||
.ThenBy(row => row.Day)
|
||||
.Select(row => new TokenUsageAggregateDto(
|
||||
$"{row.Year:D4}-{row.Month:D2}-{row.Day:D2}",
|
||||
row.Prompt,
|
||||
row.Completion,
|
||||
row.Total,
|
||||
row.Count))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
// Агрегат по тенантам (ключ — Guid "D"), порядок — по убыванию total.
|
||||
// source: Отфильтрованный запрос.
|
||||
// ct: Токен отмены.
|
||||
// Возвращает: Строки агрегатов по тенантам.
|
||||
private static async Task<IReadOnlyList<TokenUsageAggregateDto>> AggregateByTenantAsync(IQueryable<TokenUsageEventEntity> source, CancellationToken ct)
|
||||
{
|
||||
var rows = await source
|
||||
.GroupBy(e => e.TenantId)
|
||||
.Select(group => new
|
||||
{
|
||||
group.Key,
|
||||
Prompt = group.Sum(x => x.PromptTokens),
|
||||
Completion = group.Sum(x => x.CompletionTokens),
|
||||
Total = group.Sum(x => x.TotalTokens),
|
||||
Count = group.LongCount(),
|
||||
})
|
||||
.ToListAsync(ct);
|
||||
|
||||
return rows
|
||||
.OrderByDescending(row => row.Total)
|
||||
.Select(row => new TokenUsageAggregateDto(
|
||||
row.Key.ToString("D"),
|
||||
row.Prompt,
|
||||
row.Completion,
|
||||
row.Total,
|
||||
row.Count))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
// Агрегат по строковому ключу (провайдер/модель), порядок — по убыванию total.
|
||||
// source: Отфильтрованный запрос.
|
||||
// keySelector: Селектор ключа группы (провайдер/модель).
|
||||
// ct: Токен отмены.
|
||||
// Возвращает: Строки агрегатов по ключу.
|
||||
private static async Task<IReadOnlyList<TokenUsageAggregateDto>> AggregateByStringAsync(
|
||||
IQueryable<TokenUsageEventEntity> source,
|
||||
Expression<Func<TokenUsageEventEntity, string>> keySelector,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var rows = await source
|
||||
.GroupBy(keySelector)
|
||||
.Select(group => new
|
||||
{
|
||||
Key = group.Key,
|
||||
Prompt = group.Sum(x => x.PromptTokens),
|
||||
Completion = group.Sum(x => x.CompletionTokens),
|
||||
Total = group.Sum(x => x.TotalTokens),
|
||||
Count = group.LongCount(),
|
||||
})
|
||||
.ToListAsync(ct);
|
||||
|
||||
return rows
|
||||
.OrderByDescending(row => row.Total)
|
||||
.Select(row => new TokenUsageAggregateDto(row.Key, row.Prompt, row.Completion, row.Total, row.Count))
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
using Deal.Infrastructure.Persistence.Configurations;
|
||||
using Deal.Infrastructure.Persistence.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Deal.Infrastructure.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// Бессхемный DbContext тенанта
|
||||
/// </summary>
|
||||
public sealed class TenantDbContext(DbContextOptions<TenantDbContext> options) : DbContext(options)
|
||||
{
|
||||
/// <summary>
|
||||
/// Настройки тенанта
|
||||
/// </summary>
|
||||
public DbSet<TenantSettingEntity> Settings => Set<TenantSettingEntity>();
|
||||
|
||||
/// <summary>
|
||||
/// Карточки дашборда
|
||||
/// </summary>
|
||||
public DbSet<CardEntity> Cards => Set<CardEntity>();
|
||||
|
||||
/// <summary>
|
||||
/// Комментарии карточек
|
||||
/// </summary>
|
||||
public DbSet<LeadCommentEntity> LeadComments => Set<LeadCommentEntity>();
|
||||
|
||||
/// <summary>
|
||||
/// Журнал действий над карточками для обучения ML
|
||||
/// </summary>
|
||||
public DbSet<CardMoveEntity> CardMoves => Set<CardMoveEntity>();
|
||||
|
||||
/// <summary>
|
||||
/// Очередь обучающих сигналов ML
|
||||
/// </summary>
|
||||
public DbSet<MlOutboxEntity> MlOutbox => Set<MlOutboxEntity>();
|
||||
|
||||
/// <summary>
|
||||
/// Очередь входящих сообщений пайплайна
|
||||
/// </summary>
|
||||
public DbSet<QueueItemEntity> QueueItems => Set<QueueItemEntity>();
|
||||
|
||||
/// <summary>
|
||||
/// Отсев пайплайна
|
||||
/// </summary>
|
||||
public DbSet<RejectedItemEntity> RejectedItems => Set<RejectedItemEntity>();
|
||||
|
||||
/// <summary>
|
||||
/// Дедуп-хэши текстов
|
||||
/// </summary>
|
||||
public DbSet<DedupEntryEntity> DedupEntries => Set<DedupEntryEntity>();
|
||||
|
||||
/// <summary>
|
||||
/// Единые контейнеры карточек
|
||||
/// </summary>
|
||||
public DbSet<ContainerEntity> Containers => Set<ContainerEntity>();
|
||||
|
||||
/// <summary>
|
||||
/// Каталог диалогов/каналов Telegram.
|
||||
/// </summary>
|
||||
public DbSet<DialogEntity> Dialogs => Set<DialogEntity>();
|
||||
|
||||
/// <summary>
|
||||
/// Превью-сообщения диалогов.
|
||||
/// </summary>
|
||||
public DbSet<TgMessageEntity> TgMessages => Set<TgMessageEntity>();
|
||||
|
||||
/// <summary>
|
||||
/// Задачи поиска Discovery.
|
||||
/// </summary>
|
||||
public DbSet<DiscTaskEntity> DiscTasks => Set<DiscTaskEntity>();
|
||||
|
||||
/// <summary>
|
||||
/// Кандидаты задач Discovery.
|
||||
/// </summary>
|
||||
public DbSet<DiscCandidateEntity> DiscCandidates => Set<DiscCandidateEntity>();
|
||||
|
||||
/// <summary>
|
||||
/// Чёрный список Discovery.
|
||||
/// </summary>
|
||||
public DbSet<DiscBlacklistEntity> DiscBlacklist => Set<DiscBlacklistEntity>();
|
||||
|
||||
/// <summary>
|
||||
/// Лог событий задач Discovery.
|
||||
/// </summary>
|
||||
public DbSet<DiscLogEntity> DiscLog => Set<DiscLogEntity>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.ApplyConfiguration(new TenantSettingConfiguration());
|
||||
modelBuilder.ApplyConfiguration(new CardConfiguration());
|
||||
modelBuilder.ApplyConfiguration(new LeadCommentConfiguration());
|
||||
modelBuilder.ApplyConfiguration(new CardMoveConfiguration());
|
||||
modelBuilder.ApplyConfiguration(new MlOutboxConfiguration());
|
||||
modelBuilder.ApplyConfiguration(new QueueItemConfiguration());
|
||||
modelBuilder.ApplyConfiguration(new RejectedItemConfiguration());
|
||||
modelBuilder.ApplyConfiguration(new DedupEntryConfiguration());
|
||||
modelBuilder.ApplyConfiguration(new ContainerConfiguration());
|
||||
modelBuilder.ApplyConfiguration(new DialogConfiguration());
|
||||
modelBuilder.ApplyConfiguration(new TgMessageConfiguration());
|
||||
modelBuilder.ApplyConfiguration(new DiscTaskConfiguration());
|
||||
modelBuilder.ApplyConfiguration(new DiscCandidateConfiguration());
|
||||
modelBuilder.ApplyConfiguration(new DiscBlacklistConfiguration());
|
||||
modelBuilder.ApplyConfiguration(new DiscLogConfiguration());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Design;
|
||||
|
||||
namespace Deal.Infrastructure.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// Фабрика для dotnet-ef
|
||||
/// </summary>
|
||||
public sealed class TenantDbDesignTimeFactory : IDesignTimeDbContextFactory<TenantDbContext>
|
||||
{
|
||||
public TenantDbContext CreateDbContext(string[] args)
|
||||
{
|
||||
string connectionString = Environment.GetEnvironmentVariable("DEAL_PG_CONNECTION")
|
||||
?? "Host=localhost;Port=5433;Database=deal;Username=deal;Password=deal_dev_password";
|
||||
var options = new DbContextOptionsBuilder<TenantDbContext>()
|
||||
.UseNpgsql(connectionString, npgsql => npgsql.MigrationsHistoryTable("__TenantMigrationsHistory"))
|
||||
.Options;
|
||||
return new TenantDbContext(options);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user