Deal — единая кодовая база
ci / build-test (push) Canceled after 0s

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:
Rustam Khalimov
2026-09-11 23:56:47 +03:00
commit 27c7831910
1383 changed files with 158436 additions and 0 deletions
@@ -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();
}
}
@@ -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);
}
}
@@ -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 });
}
}
@@ -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; }
}
@@ -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
}
}
}
@@ -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");
}
}
}
@@ -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);
}
}