diff --git a/src/Nocr.TextMatcher.AppServices/TextMatches/Repositories/ITextMatchRepository.cs b/src/Nocr.TextMatcher.AppServices/TextMatches/Repositories/ITextMatchRepository.cs
new file mode 100644
index 0000000..faca42d
--- /dev/null
+++ b/src/Nocr.TextMatcher.AppServices/TextMatches/Repositories/ITextMatchRepository.cs
@@ -0,0 +1,24 @@
+namespace Nocr.TextMatcher.AppServices.TextMatches.Repositories;
+
+public interface ITextMatchRepository
+{
+ ///
+ /// Gets the latest version of a match for a specific message and subscription
+ ///
+ Task GetLatestByMessageAndSubscription(long messageId, long subscriptionId, CancellationToken cancellationToken = default);
+
+ ///
+ /// Gets all matches for a specific message across all subscriptions
+ ///
+ Task> GetByMessageId(long messageId, CancellationToken cancellationToken = default);
+
+ ///
+ /// Creates a new match record
+ ///
+ Task Create(TextMatch match, CancellationToken cancellationToken = default);
+
+ ///
+ /// Gets the current version number for a message-subscription pair (0 if not exists)
+ ///
+ Task GetCurrentVersion(long messageId, long subscriptionId, CancellationToken cancellationToken = default);
+}
diff --git a/src/Nocr.TextMatcher.AppServices/TextMatches/TextMatch.cs b/src/Nocr.TextMatcher.AppServices/TextMatches/TextMatch.cs
new file mode 100644
index 0000000..0424749
--- /dev/null
+++ b/src/Nocr.TextMatcher.AppServices/TextMatches/TextMatch.cs
@@ -0,0 +1,121 @@
+namespace Nocr.TextMatcher.AppServices.TextMatches;
+
+///
+/// Represents a historical record of a text match between a message and a subscription.
+/// Each update to the same message creates a new version.
+///
+public sealed class TextMatch
+{
+ public long Id { get; private set; }
+
+ ///
+ /// Telegram message ID
+ ///
+ public long MessageId { get; private set; }
+
+ ///
+ /// Text subscription that matched
+ ///
+ public long SubscriptionId { get; private set; }
+
+ ///
+ /// User ID who owns the subscription
+ ///
+ public long UserId { get; private set; }
+
+ ///
+ /// Telegram chat username where the message was posted
+ ///
+ public string ChatUsername { get; private set; }
+
+ ///
+ /// Message text at the time of this version
+ ///
+ public string MessageText { get; private set; }
+
+ ///
+ /// Author of the message
+ ///
+ public string From { get; private set; }
+
+ ///
+ /// Version number (1 for first match, increments with each update)
+ ///
+ public int Version { get; private set; }
+
+ ///
+ /// When the original message was posted in Telegram
+ ///
+ public DateTimeOffset MessageOccuredDateTime { get; private set; }
+
+ ///
+ /// When this match/version was created in our system
+ ///
+ public DateTimeOffset CreatedDateTime { get; private set; }
+
+ private TextMatch(
+ long messageId,
+ long subscriptionId,
+ long userId,
+ string chatUsername,
+ string messageText,
+ string from,
+ int version,
+ DateTimeOffset messageOccuredDateTime,
+ DateTimeOffset createdDateTime)
+ {
+ MessageId = messageId;
+ SubscriptionId = subscriptionId;
+ UserId = userId;
+ ChatUsername = chatUsername;
+ MessageText = messageText;
+ From = from;
+ Version = version;
+ MessageOccuredDateTime = messageOccuredDateTime;
+ CreatedDateTime = createdDateTime;
+ }
+
+ public static TextMatch Create(
+ long messageId,
+ long subscriptionId,
+ long userId,
+ string chatUsername,
+ string messageText,
+ string from,
+ int version,
+ DateTimeOffset messageOccuredDateTime,
+ DateTimeOffset createdDateTime)
+ {
+ if (messageId <= 0)
+ throw new ArgumentException("Message ID must be greater than 0", nameof(messageId));
+
+ if (subscriptionId <= 0)
+ throw new ArgumentException("Subscription ID must be greater than 0", nameof(subscriptionId));
+
+ if (userId <= 0)
+ throw new ArgumentException("User ID must be greater than 0", nameof(userId));
+
+ if (string.IsNullOrWhiteSpace(chatUsername))
+ throw new ArgumentException("Chat username cannot be empty", nameof(chatUsername));
+
+ if (string.IsNullOrWhiteSpace(messageText))
+ throw new ArgumentException("Message text cannot be empty", nameof(messageText));
+
+ if (string.IsNullOrWhiteSpace(from))
+ throw new ArgumentException("From cannot be empty", nameof(from));
+
+ if (version <= 0)
+ throw new ArgumentException("Version must be greater than 0", nameof(version));
+
+ return new TextMatch(
+ messageId,
+ subscriptionId,
+ userId,
+ chatUsername,
+ messageText,
+ from,
+ version,
+ messageOccuredDateTime,
+ createdDateTime);
+ }
+}
diff --git a/src/Nocr.TextMatcher.AppServices/TextSubscriptions/EventHandlers/MessageReceivedHandler.cs b/src/Nocr.TextMatcher.AppServices/TextSubscriptions/EventHandlers/MessageReceivedHandler.cs
index d17bc69..eed958b 100644
--- a/src/Nocr.TextMatcher.AppServices/TextSubscriptions/EventHandlers/MessageReceivedHandler.cs
+++ b/src/Nocr.TextMatcher.AppServices/TextSubscriptions/EventHandlers/MessageReceivedHandler.cs
@@ -1,5 +1,7 @@
using Microsoft.Extensions.Logging;
using Nocr.TelegramListener.Async.Api.Contracts.Events;
+using Nocr.TextMatcher.AppServices.TextMatches;
+using Nocr.TextMatcher.AppServices.TextMatches.Repositories;
using Nocr.TextMatcher.AppServices.TextSubscriptions.Repositories;
using Nocr.TextMatcher.Async.Api.Contracts;
using Nocr.TextMatcher.Core.Dates;
@@ -12,17 +14,20 @@ public sealed class MessageReceivedHandler : IHandleMessages
{
private readonly ILogger _logger;
private readonly IBus _bus;
- private readonly ITextSubscriptionRepository _textSubscriptionService;
+ private readonly ITextSubscriptionRepository _textSubscriptionRepository;
+ private readonly ITextMatchRepository _textMatchRepository;
private readonly ICurrentDateProvider _dateProvider;
public MessageReceivedHandler(ILogger logger,
IBus bus,
ITextSubscriptionRepository textSubscriptionRepository,
+ ITextMatchRepository textMatchRepository,
ICurrentDateProvider dateProvider)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_bus = bus ?? throw new ArgumentNullException(nameof(bus));
- _textSubscriptionService = textSubscriptionRepository ?? throw new ArgumentNullException(nameof(textSubscriptionRepository));
+ _textSubscriptionRepository = textSubscriptionRepository ?? throw new ArgumentNullException(nameof(textSubscriptionRepository));
+ _textMatchRepository = textMatchRepository ?? throw new ArgumentNullException(nameof(textMatchRepository));
_dateProvider = dateProvider ?? throw new ArgumentNullException(nameof(dateProvider));
}
@@ -30,28 +35,98 @@ public sealed class MessageReceivedHandler : IHandleMessages
{
_logger.LogInformation("Received message: {@Message}.", message);
- var matches = await _textSubscriptionService.Get();
+ var subscriptions = await _textSubscriptionRepository.Get();
- foreach (var match in matches.Where(x => x.Active))
+ foreach (var subscription in subscriptions.Where(x => x.Active))
{
- if (match.IsMatches(message.ChatUsername, message.Text))
+ if (subscription.IsMatches(message.ChatUsername, message.Text))
{
- _logger.LogInformation("Message {@Message} matched {@Match}.", message, match);
- var @event = new TextSubscriptionMatched
+ _logger.LogInformation("Message {@Message} matched subscription {@Subscription}.", message, subscription);
+
+ // Check if we already have a match for this message and subscription
+ var existingMatch = await _textMatchRepository.GetLatestByMessageAndSubscription(
+ message.MessageId,
+ subscription.Id);
+
+ if (existingMatch == null)
{
- SubscriptionId = match.Id,
- SubscriptionUserId = match.UserId,
- ChatUsername = match.ChatUsername,
- MessageId = message.MessageId,
- Rule = match.Rule,
- Template = match.Template,
- Text = message.Text,
- From = message.From,
- OccuredDateTime = message.OccuredDateTime,
- PublishedDateTime = _dateProvider.UtcNow
- };
-
- await _bus.Advanced.Topics.Publish(Constants.RoutingKeys.MatchedSubscriptions, @event);
+ // First time match - create version 1 and publish TextSubscriptionMatched
+ var textMatch = TextMatch.Create(
+ messageId: message.MessageId,
+ subscriptionId: subscription.Id,
+ userId: subscription.UserId,
+ chatUsername: message.ChatUsername,
+ messageText: message.Text,
+ from: message.From ?? "Unknown",
+ version: 1,
+ messageOccuredDateTime: message.OccuredDateTime,
+ createdDateTime: _dateProvider.UtcNow);
+
+ await _textMatchRepository.Create(textMatch);
+
+ var @event = new TextSubscriptionMatched
+ {
+ SubscriptionId = subscription.Id,
+ SubscriptionUserId = subscription.UserId,
+ ChatUsername = subscription.ChatUsername,
+ MessageId = message.MessageId,
+ Rule = subscription.Rule,
+ Template = subscription.Template,
+ Text = message.Text,
+ From = message.From,
+ OccuredDateTime = message.OccuredDateTime,
+ PublishedDateTime = _dateProvider.UtcNow
+ };
+
+ await _bus.Advanced.Topics.Publish(Constants.RoutingKeys.MatchedSubscriptions, @event);
+ _logger.LogInformation("Published TextSubscriptionMatched for message {MessageId}, subscription {SubscriptionId}, version 1.",
+ message.MessageId, subscription.Id);
+ }
+ else
+ {
+ // Message was updated - create new version and publish TextSubscriptionUpdated
+ // Only create new version if text actually changed
+ if (existingMatch.MessageText != message.Text)
+ {
+ var newVersion = existingMatch.Version + 1;
+
+ var textMatch = TextMatch.Create(
+ messageId: message.MessageId,
+ subscriptionId: subscription.Id,
+ userId: subscription.UserId,
+ chatUsername: message.ChatUsername,
+ messageText: message.Text,
+ from: message.From ?? "Unknown",
+ version: newVersion,
+ messageOccuredDateTime: message.OccuredDateTime,
+ createdDateTime: _dateProvider.UtcNow);
+
+ await _textMatchRepository.Create(textMatch);
+
+ var updateEvent = new TextSubscriptionUpdated
+ {
+ SubscriptionId = subscription.Id,
+ SubscriptionUserId = subscription.UserId,
+ ChatUsername = subscription.ChatUsername,
+ MessageId = message.MessageId,
+ Rule = subscription.Rule,
+ Template = subscription.Template,
+ Text = message.Text,
+ From = message.From,
+ Version = newVersion,
+ OccuredDateTime = message.OccuredDateTime,
+ PublishedDateTime = _dateProvider.UtcNow
+ };
+
+ await _bus.Advanced.Topics.Publish(Constants.RoutingKeys.MatchedSubscriptions, updateEvent);
+ _logger.LogInformation("Published TextSubscriptionUpdated for message {MessageId}, subscription {SubscriptionId}, version {Version}.",
+ message.MessageId, subscription.Id, newVersion);
+ }
+ else
+ {
+ _logger.LogDebug("Message {MessageId} text unchanged, skipping duplicate notification.", message.MessageId);
+ }
+ }
}
}
}
diff --git a/src/Nocr.TextMatcher.Async.Api.Contracts/TextSubscriptionUpdated.cs b/src/Nocr.TextMatcher.Async.Api.Contracts/TextSubscriptionUpdated.cs
new file mode 100644
index 0000000..816a3e2
--- /dev/null
+++ b/src/Nocr.TextMatcher.Async.Api.Contracts/TextSubscriptionUpdated.cs
@@ -0,0 +1,66 @@
+using Nocr.TextMatcher.Contracts;
+
+namespace Nocr.TextMatcher.Async.Api.Contracts;
+
+///
+/// Event published when a previously matched message is updated (edited in Telegram)
+///
+public class TextSubscriptionUpdated : IEvent
+{
+ public Guid Id => Guid.NewGuid();
+
+ ///
+ /// Идентификатор матча
+ ///
+ public long SubscriptionId { get; set; }
+
+ ///
+ /// Идентификатор владельца матча
+ ///
+ public long SubscriptionUserId { get; set; }
+
+ ///
+ /// Username чата
+ ///
+ public required string ChatUsername { get; set; }
+
+ ///
+ /// Идентификатор сообщения
+ ///
+ public long MessageId { get; set; }
+
+ ///
+ /// Правило совпадения
+ ///
+ public TextSubscriptionRule Rule { get; set; }
+
+ ///
+ /// Шаблон совпадения
+ ///
+ public required string Template { get; set; }
+
+ ///
+ /// Имя или username отправителя
+ ///
+ public string? From { get; set; }
+
+ ///
+ /// Обновленный текст сообщения
+ ///
+ public required string Text { get; set; }
+
+ ///
+ /// Номер версии сообщения (2, 3, 4, ...)
+ ///
+ public int Version { get; set; }
+
+ ///
+ /// Дата получения оригинального сообщения слушателем
+ ///
+ public DateTimeOffset OccuredDateTime { get; set; }
+
+ ///
+ /// Дата публикации события в очередь
+ ///
+ public DateTimeOffset PublishedDateTime { get; set; }
+}
diff --git a/src/Nocr.TextMatcher.Migrator/Migrations/20251014085918_AddTextMatchesTable.Designer.cs b/src/Nocr.TextMatcher.Migrator/Migrations/20251014085918_AddTextMatchesTable.Designer.cs
new file mode 100644
index 0000000..bd10b20
--- /dev/null
+++ b/src/Nocr.TextMatcher.Migrator/Migrations/20251014085918_AddTextMatchesTable.Designer.cs
@@ -0,0 +1,117 @@
+//
+using System;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Metadata;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Nocr.TextMatcher.Persistence;
+
+#nullable disable
+
+namespace Nocr.TextMatcher.Migrator.Migrations
+{
+ [DbContext(typeof(TextMatcherContext))]
+ [Migration("20251014085918_AddTextMatchesTable")]
+ partial class AddTextMatchesTable
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "8.0.3")
+ .HasAnnotation("Relational:MaxIdentifierLength", 64);
+
+ MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder);
+
+ modelBuilder.Entity("Nocr.TextMatcher.AppServices.TextMatches.TextMatch", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint");
+
+ MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id"));
+
+ b.Property("ChatUsername")
+ .IsRequired()
+ .HasMaxLength(1024)
+ .HasColumnType("varchar(1024)");
+
+ b.Property("CreatedDateTime")
+ .HasColumnType("datetime(6)");
+
+ b.Property("From")
+ .IsRequired()
+ .HasMaxLength(512)
+ .HasColumnType("varchar(512)");
+
+ b.Property("MessageId")
+ .HasColumnType("int");
+
+ b.Property("MessageOccuredDateTime")
+ .HasColumnType("datetime(6)");
+
+ b.Property("MessageText")
+ .IsRequired()
+ .HasMaxLength(4096)
+ .HasColumnType("varchar(4096)");
+
+ b.Property("SubscriptionId")
+ .HasColumnType("bigint");
+
+ b.Property("UserId")
+ .HasColumnType("bigint");
+
+ b.Property("Version")
+ .HasColumnType("int");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId")
+ .HasDatabaseName("IX_TextMatches_UserId");
+
+ b.HasIndex("MessageId", "SubscriptionId")
+ .HasDatabaseName("IX_TextMatches_MessageId_SubscriptionId");
+
+ b.ToTable("TextMatches", (string)null);
+ });
+
+ modelBuilder.Entity("Nocr.TextMatcher.AppServices.TextSubscriptions.TextSubscription", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint");
+
+ MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id"));
+
+ b.Property("Active")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("ChatUsername")
+ .IsRequired()
+ .HasMaxLength(1024)
+ .HasColumnType("varchar(1024)");
+
+ b.Property("CreatedDateTime")
+ .HasColumnType("datetime(6)");
+
+ b.Property("Rule")
+ .HasColumnType("int");
+
+ b.Property("Template")
+ .IsRequired()
+ .HasMaxLength(1024)
+ .HasColumnType("varchar(1024)");
+
+ b.Property("UserId")
+ .HasColumnType("bigint");
+
+ b.HasKey("Id");
+
+ b.ToTable("TextSubscriptions");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/src/Nocr.TextMatcher.Migrator/Migrations/20251014085918_AddTextMatchesTable.cs b/src/Nocr.TextMatcher.Migrator/Migrations/20251014085918_AddTextMatchesTable.cs
new file mode 100644
index 0000000..22e4111
--- /dev/null
+++ b/src/Nocr.TextMatcher.Migrator/Migrations/20251014085918_AddTextMatchesTable.cs
@@ -0,0 +1,58 @@
+using System;
+using Microsoft.EntityFrameworkCore.Metadata;
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace Nocr.TextMatcher.Migrator.Migrations
+{
+ ///
+ public partial class AddTextMatchesTable : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.CreateTable(
+ name: "TextMatches",
+ columns: table => new
+ {
+ Id = table.Column(type: "bigint", nullable: false)
+ .Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
+ MessageId = table.Column(type: "bigint", nullable: false),
+ SubscriptionId = table.Column(type: "bigint", nullable: false),
+ UserId = table.Column(type: "bigint", nullable: false),
+ ChatUsername = table.Column(type: "varchar(1024)", maxLength: 1024, nullable: false)
+ .Annotation("MySql:CharSet", "utf8mb4"),
+ MessageText = table.Column(type: "varchar(4096)", maxLength: 4096, nullable: false)
+ .Annotation("MySql:CharSet", "utf8mb4"),
+ From = table.Column(type: "varchar(512)", maxLength: 512, nullable: false)
+ .Annotation("MySql:CharSet", "utf8mb4"),
+ Version = table.Column(type: "int", nullable: false),
+ MessageOccuredDateTime = table.Column(type: "datetime(6)", nullable: false),
+ CreatedDateTime = table.Column(type: "datetime(6)", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_TextMatches", x => x.Id);
+ })
+ .Annotation("MySql:CharSet", "utf8mb4");
+
+ migrationBuilder.CreateIndex(
+ name: "IX_TextMatches_MessageId_SubscriptionId",
+ table: "TextMatches",
+ columns: new[] { "MessageId", "SubscriptionId" });
+
+ migrationBuilder.CreateIndex(
+ name: "IX_TextMatches_UserId",
+ table: "TextMatches",
+ column: "UserId");
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropTable(
+ name: "TextMatches");
+ }
+ }
+}
diff --git a/src/Nocr.TextMatcher.Migrator/Migrations/TextMatcherContextModelSnapshot.cs b/src/Nocr.TextMatcher.Migrator/Migrations/TextMatcherContextModelSnapshot.cs
index 3d22638..84df2d7 100644
--- a/src/Nocr.TextMatcher.Migrator/Migrations/TextMatcherContextModelSnapshot.cs
+++ b/src/Nocr.TextMatcher.Migrator/Migrations/TextMatcherContextModelSnapshot.cs
@@ -22,6 +22,58 @@ namespace Nocr.TextMatcher.Migrator.Migrations
MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder);
+ modelBuilder.Entity("Nocr.TextMatcher.AppServices.TextMatches.TextMatch", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint");
+
+ MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id"));
+
+ b.Property("ChatUsername")
+ .IsRequired()
+ .HasMaxLength(1024)
+ .HasColumnType("varchar(1024)");
+
+ b.Property("CreatedDateTime")
+ .HasColumnType("datetime(6)");
+
+ b.Property("From")
+ .IsRequired()
+ .HasMaxLength(512)
+ .HasColumnType("varchar(512)");
+
+ b.Property("MessageId")
+ .HasColumnType("int");
+
+ b.Property("MessageOccuredDateTime")
+ .HasColumnType("datetime(6)");
+
+ b.Property("MessageText")
+ .IsRequired()
+ .HasMaxLength(4096)
+ .HasColumnType("varchar(4096)");
+
+ b.Property("SubscriptionId")
+ .HasColumnType("bigint");
+
+ b.Property("UserId")
+ .HasColumnType("bigint");
+
+ b.Property("Version")
+ .HasColumnType("int");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId")
+ .HasDatabaseName("IX_TextMatches_UserId");
+
+ b.HasIndex("MessageId", "SubscriptionId")
+ .HasDatabaseName("IX_TextMatches_MessageId_SubscriptionId");
+
+ b.ToTable("TextMatches", (string)null);
+ });
+
modelBuilder.Entity("Nocr.TextMatcher.AppServices.TextSubscriptions.TextSubscription", b =>
{
b.Property("Id")
diff --git a/src/Nocr.TextMatcher.Persistence/ServiceCollectionExtensions.cs b/src/Nocr.TextMatcher.Persistence/ServiceCollectionExtensions.cs
index d2be272..80c7da1 100644
--- a/src/Nocr.TextMatcher.Persistence/ServiceCollectionExtensions.cs
+++ b/src/Nocr.TextMatcher.Persistence/ServiceCollectionExtensions.cs
@@ -1,7 +1,9 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
+using Nocr.TextMatcher.AppServices.TextMatches.Repositories;
using Nocr.TextMatcher.AppServices.TextSubscriptions.Repositories;
+using Nocr.TextMatcher.Persistence.TextMatches;
using Nocr.TextMatcher.Persistence.TextSubscriptions;
namespace Nocr.TextMatcher.Persistence;
@@ -12,11 +14,12 @@ public static class ServiceCollectionExtensions
{
if (services == null)
throw new ArgumentNullException(nameof(services));
-
+
if (string.IsNullOrWhiteSpace(connectionString))
throw new ArgumentNullException(nameof(connectionString));
services.AddScoped();
+ services.AddScoped();
services.AddDbContext(
(ctx, context) =>
diff --git a/src/Nocr.TextMatcher.Persistence/TextMatcherContext.cs b/src/Nocr.TextMatcher.Persistence/TextMatcherContext.cs
index 8cddaf6..e690394 100644
--- a/src/Nocr.TextMatcher.Persistence/TextMatcherContext.cs
+++ b/src/Nocr.TextMatcher.Persistence/TextMatcherContext.cs
@@ -1,5 +1,7 @@
using Microsoft.EntityFrameworkCore;
+using Nocr.TextMatcher.AppServices.TextMatches;
using Nocr.TextMatcher.AppServices.TextSubscriptions;
+using Nocr.TextMatcher.Persistence.TextMatches;
using Nocr.TextMatcher.Persistence.TextSubscriptions;
namespace Nocr.TextMatcher.Persistence;
@@ -7,6 +9,7 @@ namespace Nocr.TextMatcher.Persistence;
public class TextMatcherContext : DbContext
{
public DbSet TextSubscriptions { get; set; }
+ public DbSet TextMatches { get; set; }
public TextMatcherContext(DbContextOptions options)
: base(options)
@@ -16,6 +19,7 @@ public class TextMatcherContext : DbContext
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.ApplyConfiguration(new TextSubscriptionConfiguration());
+ modelBuilder.ApplyConfiguration(new TextMatchConfiguration());
base.OnModelCreating(modelBuilder);
}
diff --git a/src/Nocr.TextMatcher.Persistence/TextMatches/TextMatchConfiguration.cs b/src/Nocr.TextMatcher.Persistence/TextMatches/TextMatchConfiguration.cs
new file mode 100644
index 0000000..073606d
--- /dev/null
+++ b/src/Nocr.TextMatcher.Persistence/TextMatches/TextMatchConfiguration.cs
@@ -0,0 +1,53 @@
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+using Nocr.TextMatcher.AppServices.TextMatches;
+
+namespace Nocr.TextMatcher.Persistence.TextMatches;
+
+public class TextMatchConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ToTable("TextMatches");
+
+ builder.HasKey(x => x.Id);
+
+ builder.Property(x => x.MessageId)
+ .IsRequired();
+
+ builder.Property(x => x.SubscriptionId)
+ .IsRequired();
+
+ builder.Property(x => x.UserId)
+ .IsRequired();
+
+ builder.Property(x => x.ChatUsername)
+ .IsRequired()
+ .HasMaxLength(1024);
+
+ builder.Property(x => x.MessageText)
+ .IsRequired()
+ .HasMaxLength(4096); // Telegram message max length
+
+ builder.Property(x => x.From)
+ .IsRequired()
+ .HasMaxLength(512);
+
+ builder.Property(x => x.Version)
+ .IsRequired();
+
+ builder.Property(x => x.MessageOccuredDateTime)
+ .IsRequired();
+
+ builder.Property(x => x.CreatedDateTime)
+ .IsRequired();
+
+ // Create index for fast lookups by MessageId and SubscriptionId
+ builder.HasIndex(x => new { x.MessageId, x.SubscriptionId })
+ .HasDatabaseName("IX_TextMatches_MessageId_SubscriptionId");
+
+ // Create index for user queries
+ builder.HasIndex(x => x.UserId)
+ .HasDatabaseName("IX_TextMatches_UserId");
+ }
+}
diff --git a/src/Nocr.TextMatcher.Persistence/TextMatches/TextMatchRepository.cs b/src/Nocr.TextMatcher.Persistence/TextMatches/TextMatchRepository.cs
new file mode 100644
index 0000000..23b06b6
--- /dev/null
+++ b/src/Nocr.TextMatcher.Persistence/TextMatches/TextMatchRepository.cs
@@ -0,0 +1,48 @@
+using Microsoft.EntityFrameworkCore;
+using Nocr.TextMatcher.AppServices.TextMatches;
+using Nocr.TextMatcher.AppServices.TextMatches.Repositories;
+
+namespace Nocr.TextMatcher.Persistence.TextMatches;
+
+public class TextMatchRepository : ITextMatchRepository
+{
+ private readonly TextMatcherContext _context;
+
+ public TextMatchRepository(TextMatcherContext context)
+ {
+ _context = context ?? throw new ArgumentNullException(nameof(context));
+ }
+
+ public async Task GetLatestByMessageAndSubscription(long messageId, long subscriptionId, CancellationToken cancellationToken = default)
+ {
+ return await _context.TextMatches
+ .Where(x => x.MessageId == messageId && x.SubscriptionId == subscriptionId)
+ .OrderByDescending(x => x.Version)
+ .FirstOrDefaultAsync(cancellationToken);
+ }
+
+ public async Task> GetByMessageId(long messageId, CancellationToken cancellationToken = default)
+ {
+ return await _context.TextMatches
+ .Where(x => x.MessageId == messageId)
+ .OrderBy(x => x.SubscriptionId)
+ .ThenByDescending(x => x.Version)
+ .ToListAsync(cancellationToken);
+ }
+
+ public async Task Create(TextMatch match, CancellationToken cancellationToken = default)
+ {
+ await _context.TextMatches.AddAsync(match, cancellationToken);
+ await _context.SaveChangesAsync(cancellationToken);
+ return match;
+ }
+
+ public async Task GetCurrentVersion(long messageId, long subscriptionId, CancellationToken cancellationToken = default)
+ {
+ var maxVersion = await _context.TextMatches
+ .Where(x => x.MessageId == messageId && x.SubscriptionId == subscriptionId)
+ .MaxAsync(x => (int?)x.Version, cancellationToken);
+
+ return maxVersion ?? 0;
+ }
+}