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; + } +}