From 4f573da374f1a6e9c3a86cdc5cb5a2451e687323 Mon Sep 17 00:00:00 2001 From: Anatolii Grynchuk Date: Fri, 1 May 2026 23:18:41 +0300 Subject: [PATCH] feat: add repository layer with IUnitOfWork and fixed EF base - ITransaction, IUnitOfWork in DAL.Abstract - EfTransactionAdapter, EfUnitOfWork, NotificationUnitOfWork in DAL.EF - NotificationEfRepository: async-only base, fixed Exists (AnyAsync), fixed batch Add (AddRangeAsync), single SaveChangesAsync per operation - TemplateRepository, ProviderRepository, ProviderUsageRepository - ProviderUsageRepository.IncrementAsync uses atomic PostgreSQL upsert - ProviderRepository deserializes settings polymorphically via ProviderType discriminator Ref: IT-628 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../ITransaction.cs | 7 ++ .../IUnitOfWork.cs | 10 +++ .../Core/EfTransactionAdapter.cs | 23 +++++ .../Core/EfUnitOfWork.cs | 74 ++++++++++++++++ .../Core/NotificationEfRepository.cs | 53 ++++++++++++ .../Core/NotificationUnitOfWork.cs | 8 ++ .../Repositories/ProviderRepository.cs | 85 +++++++++++++++++++ .../Repositories/ProviderUsageRepository.cs | 45 ++++++++++ .../Repositories/TemplateRepository.cs | 76 +++++++++++++++++ 9 files changed, 381 insertions(+) create mode 100644 HrynCo.NotificationService.DAL.Abstract/ITransaction.cs create mode 100644 HrynCo.NotificationService.DAL.Abstract/IUnitOfWork.cs create mode 100644 HrynCo.NotificationService.DAL.EF/Core/EfTransactionAdapter.cs create mode 100644 HrynCo.NotificationService.DAL.EF/Core/EfUnitOfWork.cs create mode 100644 HrynCo.NotificationService.DAL.EF/Core/NotificationEfRepository.cs create mode 100644 HrynCo.NotificationService.DAL.EF/Core/NotificationUnitOfWork.cs create mode 100644 HrynCo.NotificationService.DAL.EF/Repositories/ProviderRepository.cs create mode 100644 HrynCo.NotificationService.DAL.EF/Repositories/ProviderUsageRepository.cs create mode 100644 HrynCo.NotificationService.DAL.EF/Repositories/TemplateRepository.cs diff --git a/HrynCo.NotificationService.DAL.Abstract/ITransaction.cs b/HrynCo.NotificationService.DAL.Abstract/ITransaction.cs new file mode 100644 index 0000000..9e75639 --- /dev/null +++ b/HrynCo.NotificationService.DAL.Abstract/ITransaction.cs @@ -0,0 +1,7 @@ +namespace HrynCo.NotificationService.DAL.Abstract; + +public interface ITransaction : IAsyncDisposable +{ + Task CommitAsync(CancellationToken cancellationToken = default); + Task RollbackAsync(CancellationToken cancellationToken = default); +} diff --git a/HrynCo.NotificationService.DAL.Abstract/IUnitOfWork.cs b/HrynCo.NotificationService.DAL.Abstract/IUnitOfWork.cs new file mode 100644 index 0000000..50b54a5 --- /dev/null +++ b/HrynCo.NotificationService.DAL.Abstract/IUnitOfWork.cs @@ -0,0 +1,10 @@ +namespace HrynCo.NotificationService.DAL.Abstract; + +public interface IUnitOfWork +{ + Task BeginTransactionAsync(CancellationToken cancellationToken = default); + ITransaction? GetCurrentTransaction(); + + Task ExecuteInTransactionAsync(Func action); + Task ExecuteInTransactionAsync(Func> action); +} diff --git a/HrynCo.NotificationService.DAL.EF/Core/EfTransactionAdapter.cs b/HrynCo.NotificationService.DAL.EF/Core/EfTransactionAdapter.cs new file mode 100644 index 0000000..37eccde --- /dev/null +++ b/HrynCo.NotificationService.DAL.EF/Core/EfTransactionAdapter.cs @@ -0,0 +1,23 @@ +using HrynCo.NotificationService.DAL.Abstract; +using Microsoft.EntityFrameworkCore.Storage; + +namespace HrynCo.NotificationService.DAL.EF.Core; + +internal sealed class EfTransactionAdapter : ITransaction +{ + private readonly IDbContextTransaction _transaction; + + internal EfTransactionAdapter(IDbContextTransaction transaction) + { + _transaction = transaction; + } + + public Task CommitAsync(CancellationToken cancellationToken = default) => + _transaction.CommitAsync(cancellationToken); + + public Task RollbackAsync(CancellationToken cancellationToken = default) => + _transaction.RollbackAsync(cancellationToken); + + public ValueTask DisposeAsync() => + _transaction.DisposeAsync(); +} diff --git a/HrynCo.NotificationService.DAL.EF/Core/EfUnitOfWork.cs b/HrynCo.NotificationService.DAL.EF/Core/EfUnitOfWork.cs new file mode 100644 index 0000000..7168b80 --- /dev/null +++ b/HrynCo.NotificationService.DAL.EF/Core/EfUnitOfWork.cs @@ -0,0 +1,74 @@ +using HrynCo.NotificationService.DAL.Abstract; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Storage; + +namespace HrynCo.NotificationService.DAL.EF.Core; + +internal abstract class EfUnitOfWork : IUnitOfWork + where TDbContext : DbContext +{ + private readonly TDbContext _context; + private EfTransactionAdapter? _currentTransaction; + + protected EfUnitOfWork(TDbContext context) + { + _context = context; + } + + public async Task BeginTransactionAsync(CancellationToken cancellationToken = default) + { + if (_currentTransaction != null) + return _currentTransaction; + + IDbContextTransaction tx = await _context.Database.BeginTransactionAsync(cancellationToken); + _currentTransaction = new EfTransactionAdapter(tx); + return _currentTransaction; + } + + public ITransaction? GetCurrentTransaction() => _currentTransaction; + + public async Task ExecuteInTransactionAsync(Func action) + { + ITransaction? existing = GetCurrentTransaction(); + bool ownsTransaction = existing is null; + ITransaction tx = existing ?? await BeginTransactionAsync(); + + try + { + await action(); + if (ownsTransaction) await tx.CommitAsync(); + } + catch + { + if (ownsTransaction) await tx.RollbackAsync(); + throw; + } + finally + { + if (ownsTransaction) await tx.DisposeAsync(); + } + } + + public async Task ExecuteInTransactionAsync(Func> action) + { + ITransaction? existing = GetCurrentTransaction(); + bool ownsTransaction = existing is null; + ITransaction tx = existing ?? await BeginTransactionAsync(); + + try + { + TResult result = await action(); + if (ownsTransaction) await tx.CommitAsync(); + return result; + } + catch + { + if (ownsTransaction) await tx.RollbackAsync(); + throw; + } + finally + { + if (ownsTransaction) await tx.DisposeAsync(); + } + } +} diff --git a/HrynCo.NotificationService.DAL.EF/Core/NotificationEfRepository.cs b/HrynCo.NotificationService.DAL.EF/Core/NotificationEfRepository.cs new file mode 100644 index 0000000..17474fd --- /dev/null +++ b/HrynCo.NotificationService.DAL.EF/Core/NotificationEfRepository.cs @@ -0,0 +1,53 @@ +using System.Linq.Expressions; +using Microsoft.EntityFrameworkCore; + +namespace HrynCo.NotificationService.DAL.EF.Core; + +internal abstract class NotificationEfRepository + where TEntity : class +{ + protected NotificationDbContext DbContext { get; } + protected DbSet DbSet { get; } + + protected NotificationEfRepository(NotificationDbContext dbContext) + { + DbContext = dbContext; + DbSet = dbContext.Set(); + } + + protected async Task AddAsync(TEntity entity, CancellationToken ct = default) + { + await DbSet.AddAsync(entity, ct); + await DbContext.SaveChangesAsync(ct); + } + + protected async Task AddRangeAsync(IEnumerable entities, CancellationToken ct = default) + { + await DbSet.AddRangeAsync(entities, ct); + await DbContext.SaveChangesAsync(ct); + } + + protected async Task UpdateAsync(TEntity entity, CancellationToken ct = default) + { + DbSet.Update(entity); + await DbContext.SaveChangesAsync(ct); + } + + protected async Task DeleteAsync(TEntity entity, CancellationToken ct = default) + { + DbSet.Remove(entity); + await DbContext.SaveChangesAsync(ct); + } + + protected async Task DeleteRangeAsync(IEnumerable entities, CancellationToken ct = default) + { + DbSet.RemoveRange(entities); + await DbContext.SaveChangesAsync(ct); + } + + protected Task ExistsAsync(Expression> predicate, CancellationToken ct = default) => + DbSet.AnyAsync(predicate, ct); + + protected Task SaveAsync(CancellationToken ct = default) => + DbContext.SaveChangesAsync(ct); +} diff --git a/HrynCo.NotificationService.DAL.EF/Core/NotificationUnitOfWork.cs b/HrynCo.NotificationService.DAL.EF/Core/NotificationUnitOfWork.cs new file mode 100644 index 0000000..f3cbf50 --- /dev/null +++ b/HrynCo.NotificationService.DAL.EF/Core/NotificationUnitOfWork.cs @@ -0,0 +1,8 @@ +namespace HrynCo.NotificationService.DAL.EF.Core; + +internal sealed class NotificationUnitOfWork : EfUnitOfWork +{ + public NotificationUnitOfWork(NotificationDbContext context) : base(context) + { + } +} diff --git a/HrynCo.NotificationService.DAL.EF/Repositories/ProviderRepository.cs b/HrynCo.NotificationService.DAL.EF/Repositories/ProviderRepository.cs new file mode 100644 index 0000000..e88fefd --- /dev/null +++ b/HrynCo.NotificationService.DAL.EF/Repositories/ProviderRepository.cs @@ -0,0 +1,85 @@ +using System.Text.Json; +using HrynCo.NotificationService.DAL.Abstract.Providers; +using HrynCo.NotificationService.DAL.Abstract.Repositories; +using HrynCo.NotificationService.DAL.EF.Core; +using HrynCo.NotificationService.DAL.EF.Entities; +using Microsoft.EntityFrameworkCore; + +namespace HrynCo.NotificationService.DAL.EF.Repositories; + +internal sealed class ProviderRepository : NotificationEfRepository, IProviderRepository +{ + public ProviderRepository(NotificationDbContext dbContext) : base(dbContext) + { + } + + public async Task> GetByServiceAsync(string serviceName, CancellationToken ct = default) + { + List entities = await DbSet + .Where(x => x.ServiceName == serviceName) + .OrderBy(x => x.Priority) + .ToListAsync(ct); + + return entities.Select(MapToDomain).ToList(); + } + + public async Task GetByIdAsync(Guid id, CancellationToken ct = default) + { + ProviderEntity? entity = await DbSet.FindAsync([id], ct); + return entity is null ? null : MapToDomain(entity); + } + + public Task AddAsync(Provider provider, CancellationToken ct = default) => + base.AddAsync(MapToEntity(provider), ct); + + public Task UpdateAsync(Provider provider, CancellationToken ct = default) + { + ProviderEntity entity = MapToEntity(provider); + entity.Updated = DateTimeOffset.UtcNow; + return base.UpdateAsync(entity, ct); + } + + public async Task DeleteAsync(Provider provider, CancellationToken ct = default) + { + ProviderEntity? entity = await DbSet.FindAsync([provider.Id], ct); + if (entity is not null) + await base.DeleteAsync(entity, ct); + } + + private static Provider MapToDomain(ProviderEntity e) => new() + { + Id = e.Id, + ServiceName = e.ServiceName, + Priority = e.Priority, + ProviderType = e.ProviderType, + Settings = DeserializeSettings(e.ProviderType, e.SettingsJson), + DailyLimit = e.DailyLimit, + MonthlyLimit = e.MonthlyLimit, + WarnThresholdPercent = e.WarnThresholdPercent, + IsActive = e.IsActive, + Created = e.Created, + Updated = e.Updated + }; + + private static ProviderEntity MapToEntity(Provider p) => new() + { + Id = p.Id, + ServiceName = p.ServiceName, + Priority = p.Priority, + ProviderType = p.ProviderType, + SettingsJson = JsonSerializer.Serialize(p.Settings), + DailyLimit = p.DailyLimit, + MonthlyLimit = p.MonthlyLimit, + WarnThresholdPercent = p.WarnThresholdPercent, + IsActive = p.IsActive, + Created = p.Created, + Updated = p.Updated + }; + + private static ProviderSettings DeserializeSettings(ProviderType type, string json) => type switch + { + ProviderType.Smtp => JsonSerializer.Deserialize(json) + ?? throw new InvalidOperationException("Failed to deserialize SMTP provider settings."), + _ => throw new InvalidOperationException($"Unknown provider type: {type}") + }; +} diff --git a/HrynCo.NotificationService.DAL.EF/Repositories/ProviderUsageRepository.cs b/HrynCo.NotificationService.DAL.EF/Repositories/ProviderUsageRepository.cs new file mode 100644 index 0000000..85f81a0 --- /dev/null +++ b/HrynCo.NotificationService.DAL.EF/Repositories/ProviderUsageRepository.cs @@ -0,0 +1,45 @@ +using HrynCo.NotificationService.DAL.Abstract.Repositories; +using HrynCo.NotificationService.DAL.EF.Core; +using HrynCo.NotificationService.DAL.EF.Entities; +using Microsoft.EntityFrameworkCore; + +namespace HrynCo.NotificationService.DAL.EF.Repositories; + +internal sealed class ProviderUsageRepository : NotificationEfRepository, IProviderUsageRepository +{ + public ProviderUsageRepository(NotificationDbContext dbContext) : base(dbContext) + { + } + + public async Task GetDailyCountAsync(Guid providerId, DateOnly date, CancellationToken ct = default) + { + ProviderUsageEntity? entity = await DbSet + .FirstOrDefaultAsync(x => x.ProviderId == providerId && x.Date == date, ct); + + return entity?.SentCount ?? 0; + } + + public async Task GetMonthlyCountAsync(Guid providerId, int year, int month, CancellationToken ct = default) + { + return await DbSet + .Where(x => x.ProviderId == providerId + && x.Date.Year == year + && x.Date.Month == month) + .SumAsync(x => x.SentCount, ct); + } + + public async Task IncrementAsync(Guid providerId, DateOnly date, CancellationToken ct = default) + { + DateTimeOffset now = DateTimeOffset.UtcNow; + + // Atomic upsert: insert with count=1 or increment existing count. + await DbContext.Database.ExecuteSqlAsync( + $""" + INSERT INTO provider_usage (id, provider_id, date, sent_count, created) + VALUES ({Guid.NewGuid()}, {providerId}, {date}, 1, {now}) + ON CONFLICT (provider_id, date) DO UPDATE SET + sent_count = provider_usage.sent_count + 1, + updated = {now} + """, ct); + } +} diff --git a/HrynCo.NotificationService.DAL.EF/Repositories/TemplateRepository.cs b/HrynCo.NotificationService.DAL.EF/Repositories/TemplateRepository.cs new file mode 100644 index 0000000..74e9794 --- /dev/null +++ b/HrynCo.NotificationService.DAL.EF/Repositories/TemplateRepository.cs @@ -0,0 +1,76 @@ +using HrynCo.NotificationService.DAL.Abstract.Repositories; +using HrynCo.NotificationService.DAL.Abstract.Templates; +using HrynCo.NotificationService.DAL.EF.Core; +using HrynCo.NotificationService.DAL.EF.Entities; +using Microsoft.EntityFrameworkCore; + +namespace HrynCo.NotificationService.DAL.EF.Repositories; + +internal sealed class TemplateRepository : NotificationEfRepository, ITemplateRepository +{ + public TemplateRepository(NotificationDbContext dbContext) : base(dbContext) + { + } + + public async Task> GetByServiceAsync(string serviceName, CancellationToken ct = default) + { + List entities = await DbSet + .Where(x => x.ServiceName == serviceName) + .ToListAsync(ct); + + return entities.Select(MapToDomain).ToList(); + } + + public async Task GetAsync(string serviceName, string key, string languageCode, CancellationToken ct = default) + { + TemplateEntity? entity = await DbSet.FirstOrDefaultAsync( + x => x.ServiceName == serviceName && x.Key == key && x.LanguageCode == languageCode, ct); + + return entity is null ? null : MapToDomain(entity); + } + + public Task AddAsync(Template template, CancellationToken ct = default) => + base.AddAsync(MapToEntity(template), ct); + + public Task UpdateAsync(Template template, CancellationToken ct = default) + { + TemplateEntity entity = MapToEntity(template); + entity.Updated = DateTimeOffset.UtcNow; + return base.UpdateAsync(entity, ct); + } + + public async Task DeleteAsync(Template template, CancellationToken ct = default) + { + TemplateEntity? entity = await DbSet.FindAsync([template.Id], ct); + if (entity is not null) + await base.DeleteAsync(entity, ct); + } + + private static Template MapToDomain(TemplateEntity e) => new() + { + Id = e.Id, + ServiceName = e.ServiceName, + Key = e.Key, + LanguageCode = e.LanguageCode, + Subject = e.Subject, + HtmlBody = e.HtmlBody, + TextBody = e.TextBody, + Variables = e.Variables.Select(v => new TemplateVariable { Name = v.Name, Required = v.Required }).ToList(), + Created = e.Created, + Updated = e.Updated + }; + + private static TemplateEntity MapToEntity(Template t) => new() + { + Id = t.Id, + ServiceName = t.ServiceName, + Key = t.Key, + LanguageCode = t.LanguageCode, + Subject = t.Subject, + HtmlBody = t.HtmlBody, + TextBody = t.TextBody, + Variables = t.Variables.Select(v => new TemplateVariableData { Name = v.Name, Required = v.Required }).ToList(), + Created = t.Created, + Updated = t.Updated + }; +}