diff --git a/.gitignore b/.gitignore index 0808c4a..e418e6a 100644 --- a/.gitignore +++ b/.gitignore @@ -480,3 +480,6 @@ $RECYCLE.BIN/ # Vim temporary swap files *.swp + +# Local Docker Compose secrets and ports +docker/environments/.env.local diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..05ff5d6 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,27 @@ +# HrynCo Notification Service Agent Rules + +## Git workflow + +- Treat `main` as the only default and integration branch for this repository. +- Start every task branch from the latest `origin/main`. +- Before creating a task branch, fetch the remote, switch to `main`, and fast-forward it from `origin/main`. +- Create the task branch only after confirming that local `main` matches `origin/main`. +- Open pull requests from the task branch into `main`. +- Do not use `development` as the base or pull-request target for new work in this repository. +- Do not commit, push, publish, deploy, or merge unless the repository owner explicitly requests that step. + +Recommended branch preparation: + +```text +git fetch origin +git switch main +git pull --ff-only origin main +git switch -c +``` + +## Delivery + +- Keep changes focused and preserve backward compatibility where practical. +- Update the matching repository documentation when behavior, contracts, configuration, routing, or workflows change. +- Run the solution build and relevant tests before handoff. +- Use Conventional Commit messages with a lowercase subject and finish the commit body with `Ref: ` when an issue exists. diff --git a/Directory.Build.props b/Directory.Build.props index e42216a..058246e 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,5 +1 @@ - - - true - - + diff --git a/Directory.Packages.props b/Directory.Packages.props index 170d1c4..d1fc679 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -1,44 +1,42 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/HrynCo.NotificationService.Contracts/HrynCo.NotificationService.Contracts.csproj b/HrynCo.NotificationService.Contracts/HrynCo.NotificationService.Contracts.csproj index ca9fe6d..d5d878d 100644 --- a/HrynCo.NotificationService.Contracts/HrynCo.NotificationService.Contracts.csproj +++ b/HrynCo.NotificationService.Contracts/HrynCo.NotificationService.Contracts.csproj @@ -4,6 +4,12 @@ net10.0 enable enable + HrynCo.NotificationService.Contracts + HrynCo + RabbitMQ message contracts for HrynCo.NotificationService. + hrynco notification email rabbitmq contracts + git + https://gitea.grynco.com.ua/hrynco/hrynco-notification-service.git diff --git a/HrynCo.NotificationService.DAL.Abstract/Entities/Entity.cs b/HrynCo.NotificationService.DAL.Abstract/Entities/Entity.cs deleted file mode 100644 index fcec809..0000000 --- a/HrynCo.NotificationService.DAL.Abstract/Entities/Entity.cs +++ /dev/null @@ -1,17 +0,0 @@ -namespace HrynCo.NotificationService.DAL.Abstract.Entities; - -public abstract class Entity : IEntity where TId : struct -{ - public TId Id { get; set; } - public DateTimeOffset Created { get; set; } - public DateTimeOffset? Updated { get; set; } -} - -public abstract class Entity : Entity -{ - protected Entity() - { - Id = Guid.NewGuid(); - Created = DateTimeOffset.UtcNow; - } -} diff --git a/HrynCo.NotificationService.DAL.Abstract/Entities/IEntity.cs b/HrynCo.NotificationService.DAL.Abstract/Entities/IEntity.cs deleted file mode 100644 index a1445ab..0000000 --- a/HrynCo.NotificationService.DAL.Abstract/Entities/IEntity.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace HrynCo.NotificationService.DAL.Abstract.Entities; - -public interface IEntity where TId : struct -{ - TId Id { get; set; } - DateTimeOffset Created { get; set; } - DateTimeOffset? Updated { get; set; } -} diff --git a/HrynCo.NotificationService.DAL.Abstract/HrynCo.NotificationService.DAL.Abstract.csproj b/HrynCo.NotificationService.DAL.Abstract/HrynCo.NotificationService.DAL.Abstract.csproj index b760144..bcb034d 100644 --- a/HrynCo.NotificationService.DAL.Abstract/HrynCo.NotificationService.DAL.Abstract.csproj +++ b/HrynCo.NotificationService.DAL.Abstract/HrynCo.NotificationService.DAL.Abstract.csproj @@ -6,4 +6,8 @@ enable + + + + diff --git a/HrynCo.NotificationService.DAL.Abstract/ITransaction.cs b/HrynCo.NotificationService.DAL.Abstract/ITransaction.cs deleted file mode 100644 index 9e75639..0000000 --- a/HrynCo.NotificationService.DAL.Abstract/ITransaction.cs +++ /dev/null @@ -1,7 +0,0 @@ -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 deleted file mode 100644 index 3e1bf64..0000000 --- a/HrynCo.NotificationService.DAL.Abstract/IUnitOfWork.cs +++ /dev/null @@ -1,11 +0,0 @@ -namespace HrynCo.NotificationService.DAL.Abstract; - -public interface IUnitOfWork -{ - Task SaveChangesAsync(CancellationToken cancellationToken = default); - Task BeginTransactionAsync(CancellationToken cancellationToken = default); - ITransaction? GetCurrentTransaction(); - - Task ExecuteInTransactionAsync(Func action); - Task ExecuteInTransactionAsync(Func> action); -} diff --git a/HrynCo.NotificationService.DAL.Abstract/Providers/EmailChannel.cs b/HrynCo.NotificationService.DAL.Abstract/Providers/EmailChannel.cs index b288734..5b1d1c6 100644 --- a/HrynCo.NotificationService.DAL.Abstract/Providers/EmailChannel.cs +++ b/HrynCo.NotificationService.DAL.Abstract/Providers/EmailChannel.cs @@ -1,4 +1,4 @@ -using HrynCo.NotificationService.DAL.Abstract.Entities; +using HrynCo.DAL.Abstract.Entities; namespace HrynCo.NotificationService.DAL.Abstract.Providers; @@ -12,4 +12,4 @@ public class EmailChannel : Entity public int? MonthlyLimit { get; set; } public int WarnThresholdPercent { get; set; } = 90; public bool IsActive { get; set; } = true; -} \ No newline at end of file +} diff --git a/HrynCo.NotificationService.DAL.Abstract/Providers/EmailChannelUsage.cs b/HrynCo.NotificationService.DAL.Abstract/Providers/EmailChannelUsage.cs index 336f284..c4d120d 100644 --- a/HrynCo.NotificationService.DAL.Abstract/Providers/EmailChannelUsage.cs +++ b/HrynCo.NotificationService.DAL.Abstract/Providers/EmailChannelUsage.cs @@ -1,13 +1,14 @@ -using HrynCo.NotificationService.DAL.Abstract.Entities; - namespace HrynCo.NotificationService.DAL.Abstract.Providers; /// /// Tracks email send counts per EmailChannel per calendar day. /// Monthly counts are derived by summing daily records within a month. /// -public class EmailChannelUsage : Entity +public class EmailChannelUsage { + public Guid Id { get; set; } + public DateTimeOffset Created { get; set; } + public DateTimeOffset? Updated { get; set; } public Guid ProviderId { get; set; } public DateOnly Date { get; set; } public int SentCount { get; set; } diff --git a/HrynCo.NotificationService.DAL.Abstract/Repositories/IEmailTemplateRepository.cs b/HrynCo.NotificationService.DAL.Abstract/Repositories/IEmailTemplateRepository.cs index b2cac8b..1bac856 100644 --- a/HrynCo.NotificationService.DAL.Abstract/Repositories/IEmailTemplateRepository.cs +++ b/HrynCo.NotificationService.DAL.Abstract/Repositories/IEmailTemplateRepository.cs @@ -4,10 +4,10 @@ namespace HrynCo.NotificationService.DAL.Abstract.Repositories; public interface IEmailTemplateRepository { - Task> GetAllAsync(CancellationToken ct = default); + Task> GetAllAsync(string? serviceName = null, string? key = null, CancellationToken ct = default); Task> GetByServiceAsync(string serviceName, CancellationToken ct = default); Task GetAsync(string serviceName, string key, string languageCode, CancellationToken ct = default); Task AddAsync(EmailTemplate template, CancellationToken ct = default); Task UpdateAsync(EmailTemplate template, CancellationToken ct = default); Task DeleteAsync(EmailTemplate template, CancellationToken ct = default); -} \ No newline at end of file +} diff --git a/HrynCo.NotificationService.DAL.Abstract/Templates/EmailTemplate.cs b/HrynCo.NotificationService.DAL.Abstract/Templates/EmailTemplate.cs index d436382..eafb069 100644 --- a/HrynCo.NotificationService.DAL.Abstract/Templates/EmailTemplate.cs +++ b/HrynCo.NotificationService.DAL.Abstract/Templates/EmailTemplate.cs @@ -1,4 +1,4 @@ -using HrynCo.NotificationService.DAL.Abstract.Entities; +using HrynCo.DAL.Abstract.Entities; namespace HrynCo.NotificationService.DAL.Abstract.Templates; diff --git a/HrynCo.NotificationService.DAL.EF/Core/EfRepository.cs b/HrynCo.NotificationService.DAL.EF/Core/EfRepository.cs deleted file mode 100644 index 2cd5661..0000000 --- a/HrynCo.NotificationService.DAL.EF/Core/EfRepository.cs +++ /dev/null @@ -1,45 +0,0 @@ -using System.Linq.Expressions; -using Microsoft.EntityFrameworkCore; - -namespace HrynCo.NotificationService.DAL.EF.Core; - -internal abstract class EfRepository - where TEntity : class -{ - protected NotificationDbContext DbContext { get; } - protected DbSet DbSet { get; } - - protected EfRepository(NotificationDbContext dbContext) - { - DbContext = dbContext; - DbSet = dbContext.Set(); - } - - protected async Task AddAsync(TEntity entity, CancellationToken ct = default) - { - await DbSet.AddAsync(entity, ct); - } - - protected async Task AddRangeAsync(IEnumerable entities, CancellationToken ct = default) - { - await DbSet.AddRangeAsync(entities, ct); - } - - protected void Update(TEntity entity) - { - DbSet.Update(entity); - } - - protected void Delete(TEntity entity) - { - DbSet.Remove(entity); - } - - protected void DeleteRange(IEnumerable entities) - { - DbSet.RemoveRange(entities); - } - - protected Task ExistsAsync(Expression> predicate, CancellationToken ct = default) => - DbSet.AnyAsync(predicate, ct); -} \ No newline at end of file diff --git a/HrynCo.NotificationService.DAL.EF/Core/EfTransactionAdapter.cs b/HrynCo.NotificationService.DAL.EF/Core/EfTransactionAdapter.cs deleted file mode 100644 index e5855af..0000000 --- a/HrynCo.NotificationService.DAL.EF/Core/EfTransactionAdapter.cs +++ /dev/null @@ -1,29 +0,0 @@ -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) - { - return _transaction.CommitAsync(cancellationToken); - } - - public Task RollbackAsync(CancellationToken cancellationToken = default) - { - return _transaction.RollbackAsync(cancellationToken); - } - - public ValueTask DisposeAsync() - { - return _transaction.DisposeAsync(); - } -} \ No newline at end of file diff --git a/HrynCo.NotificationService.DAL.EF/Core/EfUnitOfWork.cs b/HrynCo.NotificationService.DAL.EF/Core/EfUnitOfWork.cs deleted file mode 100644 index 0d45441..0000000 --- a/HrynCo.NotificationService.DAL.EF/Core/EfUnitOfWork.cs +++ /dev/null @@ -1,105 +0,0 @@ -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 Task SaveChangesAsync(CancellationToken cancellationToken = default) - { - return _context.SaveChangesAsync(cancellationToken); - } - - 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() - { - return _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(); - } - } - } -} \ No newline at end of file diff --git a/HrynCo.NotificationService.DAL.EF/Core/NotificationBaseRepository.cs b/HrynCo.NotificationService.DAL.EF/Core/NotificationBaseRepository.cs new file mode 100644 index 0000000..dcfe09e --- /dev/null +++ b/HrynCo.NotificationService.DAL.EF/Core/NotificationBaseRepository.cs @@ -0,0 +1,20 @@ +namespace HrynCo.NotificationService.DAL.EF.Core; + +using HrynCo.DAL.Abstract.Entities; +using HrynCo.DAL.EF.Core; + +public abstract class NotificationBaseRepository + : BaseRepository, NotificationDbContext, TEntity, Guid> where TEntity : Entity +{ + protected NotificationBaseRepository(NotificationDbContext dbContext) + { + DbContext = dbContext; + } + + private NotificationDbContext DbContext { get; set; } + + protected override NotificationEfRepository CreateEfRepository() + { + return new NotificationEfRepository(DbContext); + } +} \ No newline at end of file diff --git a/HrynCo.NotificationService.DAL.EF/Core/NotificationEfRepository.cs b/HrynCo.NotificationService.DAL.EF/Core/NotificationEfRepository.cs new file mode 100644 index 0000000..634751f --- /dev/null +++ b/HrynCo.NotificationService.DAL.EF/Core/NotificationEfRepository.cs @@ -0,0 +1,13 @@ +namespace HrynCo.NotificationService.DAL.EF.Core; + +using HrynCo.DAL.Abstract.Entities; +using HrynCo.DAL.EF.Core; + +public class NotificationEfRepository : BaseEfRepository + where TEntity : class, IEntity +{ + public NotificationEfRepository(NotificationDbContext dbContext) : + base(dbContext) + { + } +} \ No newline at end of file diff --git a/HrynCo.NotificationService.DAL.EF/Core/UnitOfWork.cs b/HrynCo.NotificationService.DAL.EF/Core/UnitOfWork.cs deleted file mode 100644 index 7e8ebfa..0000000 --- a/HrynCo.NotificationService.DAL.EF/Core/UnitOfWork.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace HrynCo.NotificationService.DAL.EF.Core; - -internal sealed class UnitOfWork : EfUnitOfWork -{ - public UnitOfWork(NotificationDbContext context) : base(context) - { - } -} diff --git a/HrynCo.NotificationService.DAL.EF/Entities/EmailChannelEntity.cs b/HrynCo.NotificationService.DAL.EF/Entities/EmailChannelEntity.cs index 4db02b4..7b95486 100644 --- a/HrynCo.NotificationService.DAL.EF/Entities/EmailChannelEntity.cs +++ b/HrynCo.NotificationService.DAL.EF/Entities/EmailChannelEntity.cs @@ -1,4 +1,4 @@ -using HrynCo.NotificationService.DAL.Abstract.Entities; +using HrynCo.DAL.Abstract.Entities; using HrynCo.NotificationService.DAL.Abstract.Providers; namespace HrynCo.NotificationService.DAL.EF.Entities; diff --git a/HrynCo.NotificationService.DAL.EF/Entities/EmailChannelUsageEntity.cs b/HrynCo.NotificationService.DAL.EF/Entities/EmailChannelUsageEntity.cs index 71fe531..fbba7e6 100644 --- a/HrynCo.NotificationService.DAL.EF/Entities/EmailChannelUsageEntity.cs +++ b/HrynCo.NotificationService.DAL.EF/Entities/EmailChannelUsageEntity.cs @@ -1,4 +1,4 @@ -using HrynCo.NotificationService.DAL.Abstract.Entities; +using HrynCo.DAL.Abstract.Entities; namespace HrynCo.NotificationService.DAL.EF.Entities; diff --git a/HrynCo.NotificationService.DAL.EF/Entities/EmailTemplateEntity.cs b/HrynCo.NotificationService.DAL.EF/Entities/EmailTemplateEntity.cs index f22f3d6..a68f0c5 100644 --- a/HrynCo.NotificationService.DAL.EF/Entities/EmailTemplateEntity.cs +++ b/HrynCo.NotificationService.DAL.EF/Entities/EmailTemplateEntity.cs @@ -1,4 +1,4 @@ -using HrynCo.NotificationService.DAL.Abstract.Entities; +using HrynCo.DAL.Abstract.Entities; namespace HrynCo.NotificationService.DAL.EF.Entities; diff --git a/HrynCo.NotificationService.DAL.EF/HrynCo.NotificationService.DAL.EF.csproj b/HrynCo.NotificationService.DAL.EF/HrynCo.NotificationService.DAL.EF.csproj index f073f56..216a1df 100644 --- a/HrynCo.NotificationService.DAL.EF/HrynCo.NotificationService.DAL.EF.csproj +++ b/HrynCo.NotificationService.DAL.EF/HrynCo.NotificationService.DAL.EF.csproj @@ -5,7 +5,7 @@ - + runtime; build; native; contentfiles; analyzers; buildtransitive all diff --git a/HrynCo.NotificationService.DAL.EF/Migrations/20260502154249_PendingChanges.Designer.cs b/HrynCo.NotificationService.DAL.EF/Migrations/20260502154249_PendingChanges.Designer.cs new file mode 100644 index 0000000..7886d9c --- /dev/null +++ b/HrynCo.NotificationService.DAL.EF/Migrations/20260502154249_PendingChanges.Designer.cs @@ -0,0 +1,225 @@ +// +using System; +using HrynCo.NotificationService.DAL.EF; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace HrynCo.NotificationService.DAL.EF.Migrations +{ + [DbContext(typeof(NotificationDbContext))] + [Migration("20260502154249_PendingChanges")] + partial class PendingChanges + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.5") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.EmailChannelEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Created") + .HasColumnType("timestamp with time zone") + .HasColumnName("created"); + + b.Property("DailyLimit") + .HasColumnType("integer") + .HasColumnName("daily_limit"); + + b.Property("EmailChannelType") + .HasColumnType("integer") + .HasColumnName("provider_type"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("MonthlyLimit") + .HasColumnType("integer") + .HasColumnName("monthly_limit"); + + b.Property("Priority") + .HasColumnType("integer") + .HasColumnName("priority"); + + b.Property("ServiceName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("service_name"); + + b.Property("SettingsJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("settings"); + + b.Property("Updated") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated"); + + b.Property("WarnThresholdPercent") + .HasColumnType("integer") + .HasColumnName("warn_threshold_percent"); + + b.HasKey("Id"); + + b.HasIndex("ServiceName", "Priority"); + + b.ToTable("email_channels", (string)null); + }); + + modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.EmailChannelUsageEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Created") + .HasColumnType("timestamp with time zone") + .HasColumnName("created"); + + b.Property("Date") + .HasColumnType("date") + .HasColumnName("date"); + + b.Property("ProviderId") + .HasColumnType("uuid") + .HasColumnName("provider_id"); + + b.Property("SentCount") + .HasColumnType("integer") + .HasColumnName("sent_count"); + + b.Property("Updated") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId", "Date") + .IsUnique(); + + b.ToTable("email_channel_usage", (string)null); + }); + + modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.EmailTemplateEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Created") + .HasColumnType("timestamp with time zone") + .HasColumnName("created"); + + b.Property("HtmlBody") + .IsRequired() + .HasColumnType("text") + .HasColumnName("html_body"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("key"); + + b.Property("LanguageCode") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)") + .HasColumnName("language_code"); + + b.Property("ServiceName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("service_name"); + + b.Property("Subject") + .IsRequired() + .HasColumnType("text") + .HasColumnName("subject"); + + b.Property("TextBody") + .IsRequired() + .HasColumnType("text") + .HasColumnName("text_body"); + + b.Property("Updated") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated"); + + b.HasKey("Id"); + + b.HasIndex("ServiceName", "Key", "LanguageCode") + .IsUnique(); + + b.ToTable("email_templates", (string)null); + }); + + modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.EmailChannelUsageEntity", b => + { + b.HasOne("HrynCo.NotificationService.DAL.EF.Entities.EmailChannelEntity", null) + .WithMany("UsageRecords") + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.EmailTemplateEntity", b => + { + b.OwnsMany("HrynCo.NotificationService.DAL.EF.Entities.EmailTemplateVariableData", "Variables", b1 => + { + b1.Property("EmailTemplateEntityId") + .HasColumnType("uuid"); + + b1.Property("__synthesizedOrdinal") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + b1.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasAnnotation("Relational:JsonPropertyName", "name"); + + b1.Property("Required") + .HasColumnType("boolean") + .HasAnnotation("Relational:JsonPropertyName", "required"); + + b1.HasKey("EmailTemplateEntityId", "__synthesizedOrdinal"); + + b1.ToTable("email_templates"); + + b1.ToJson("variables"); + + b1.WithOwner() + .HasForeignKey("EmailTemplateEntityId"); + }); + + b.Navigation("Variables"); + }); + + modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.EmailChannelEntity", b => + { + b.Navigation("UsageRecords"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/HrynCo.NotificationService.DAL.EF/Migrations/20260502154249_PendingChanges.cs b/HrynCo.NotificationService.DAL.EF/Migrations/20260502154249_PendingChanges.cs new file mode 100644 index 0000000..e3fbc77 --- /dev/null +++ b/HrynCo.NotificationService.DAL.EF/Migrations/20260502154249_PendingChanges.cs @@ -0,0 +1,30 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace HrynCo.NotificationService.DAL.EF.Migrations +{ + /// + public partial class PendingChanges : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddForeignKey( + name: "FK_email_channel_usage_email_channels_provider_id", + table: "email_channel_usage", + column: "provider_id", + principalTable: "email_channels", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_email_channel_usage_email_channels_provider_id", + table: "email_channel_usage"); + } + } +} diff --git a/HrynCo.NotificationService.DAL.EF/Migrations/NotificationDbContextModelSnapshot.cs b/HrynCo.NotificationService.DAL.EF/Migrations/NotificationDbContextModelSnapshot.cs index 4266c16..125decf 100644 --- a/HrynCo.NotificationService.DAL.EF/Migrations/NotificationDbContextModelSnapshot.cs +++ b/HrynCo.NotificationService.DAL.EF/Migrations/NotificationDbContextModelSnapshot.cs @@ -170,6 +170,15 @@ namespace HrynCo.NotificationService.DAL.EF.Migrations b.ToTable("email_templates", (string)null); }); + modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.EmailChannelUsageEntity", b => + { + b.HasOne("HrynCo.NotificationService.DAL.EF.Entities.EmailChannelEntity", null) + .WithMany("UsageRecords") + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.EmailTemplateEntity", b => { b.OwnsMany("HrynCo.NotificationService.DAL.EF.Entities.EmailTemplateVariableData", "Variables", b1 => @@ -202,6 +211,11 @@ namespace HrynCo.NotificationService.DAL.EF.Migrations b.Navigation("Variables"); }); + + modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.EmailChannelEntity", b => + { + b.Navigation("UsageRecords"); + }); #pragma warning restore 612, 618 } } diff --git a/HrynCo.NotificationService.DAL.EF/NotificationUnitOfWork.cs b/HrynCo.NotificationService.DAL.EF/NotificationUnitOfWork.cs new file mode 100644 index 0000000..21d8929 --- /dev/null +++ b/HrynCo.NotificationService.DAL.EF/NotificationUnitOfWork.cs @@ -0,0 +1,10 @@ +namespace HrynCo.NotificationService.DAL.EF; + +using HrynCo.DAL.EF.Core; + +public class NotificationUnitOfWork : EfUnitOfWork +{ + public NotificationUnitOfWork(NotificationDbContext context) : base(context) + { + } +} diff --git a/HrynCo.NotificationService.DAL.EF/Repositories/EmailChannelRepository.cs b/HrynCo.NotificationService.DAL.EF/Repositories/EmailChannelRepository.cs index 81f2210..8234425 100644 --- a/HrynCo.NotificationService.DAL.EF/Repositories/EmailChannelRepository.cs +++ b/HrynCo.NotificationService.DAL.EF/Repositories/EmailChannelRepository.cs @@ -1,3 +1,5 @@ +namespace HrynCo.NotificationService.DAL.EF.Repositories; + using System.Text.Json; using HrynCo.NotificationService.DAL.Abstract.Providers; using HrynCo.NotificationService.DAL.Abstract.Repositories; @@ -5,9 +7,7 @@ using HrynCo.NotificationService.DAL.EF.Core; using HrynCo.NotificationService.DAL.EF.Entities; using Microsoft.EntityFrameworkCore; -namespace HrynCo.NotificationService.DAL.EF.Repositories; - -internal sealed class EmailChannelRepository : EfRepository, IEmailChannelRepository +internal sealed class EmailChannelRepository : NotificationBaseRepository, IEmailChannelRepository { public EmailChannelRepository(NotificationDbContext dbContext) : base(dbContext) { @@ -15,20 +15,14 @@ internal sealed class EmailChannelRepository : EfRepository, public async Task> GetAllAsync(CancellationToken ct = default) { - var entities = await DbSet - .AsNoTracking() - .OrderBy(x => x.ServiceName) - .ThenBy(x => x.Priority) - .ToListAsync(ct); + var entities = await EfRepository.Get().ToListAsync(ct); return entities.Select(MapToDomain).ToList(); } public async Task> GetByServiceAsync(string serviceName, CancellationToken ct = default) { - var entities = await DbSet - .AsNoTracking() - .Where(x => x.ServiceName == serviceName) + var entities = await EfRepository.Get(x => x.ServiceName == serviceName) .OrderBy(x => x.Priority) .ToListAsync(ct); @@ -38,8 +32,7 @@ internal sealed class EmailChannelRepository : EfRepository, public async Task> GetAllWithUsageSummaryAsync( DateOnly today, CancellationToken ct = default) { - var rows = await DbSet - .AsNoTracking() + var rows = await EfRepository.Get() .OrderBy(c => c.ServiceName) .ThenBy(c => c.Priority) .Select(c => new @@ -61,30 +54,24 @@ internal sealed class EmailChannelRepository : EfRepository, public async Task GetByIdAsync(Guid id, CancellationToken ct = default) { - EmailChannelEntity? entity = await DbSet.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, ct); + EmailChannelEntity? entity = await EfRepository.GetByIdAsync(id); return entity is null ? null : MapToDomain(entity); } public Task AddAsync(EmailChannel channel, CancellationToken ct = default) { - return base.AddAsync(MapToEntity(channel), ct); + return EfRepository.AddAsync(MapToEntity(channel)); } - public Task UpdateAsync(EmailChannel channel, CancellationToken ct = default) + public async Task UpdateAsync(EmailChannel channel, CancellationToken ct = default) { EmailChannelEntity entity = MapToEntity(channel); - entity.Updated = DateTimeOffset.UtcNow; - Update(entity); - return Task.CompletedTask; + await EfRepository.UpdateAsync(entity); } public async Task DeleteAsync(EmailChannel channel, CancellationToken ct = default) { - EmailChannelEntity? entity = await DbSet.FindAsync([channel.Id], ct); - if (entity is not null) - { - Delete(entity); - } + await EfRepository.DeleteAsync(channel.Id); } private static EmailChannel MapToDomain(EmailChannelEntity e) @@ -128,8 +115,8 @@ internal sealed class EmailChannelRepository : EfRepository, return type switch { EmailChannelType.Smtp => JsonSerializer.Deserialize(json) - ?? throw new InvalidOperationException( - "Failed to deserialize SMTP EmailChannel settings."), + ?? throw new InvalidOperationException( + "Failed to deserialize SMTP EmailChannel settings."), _ => throw new InvalidOperationException($"Unknown or undefined email channel type: {type}") }; } diff --git a/HrynCo.NotificationService.DAL.EF/Repositories/EmailChannelUsageRepository.cs b/HrynCo.NotificationService.DAL.EF/Repositories/EmailChannelUsageRepository.cs index 0a79344..f8c38fb 100644 --- a/HrynCo.NotificationService.DAL.EF/Repositories/EmailChannelUsageRepository.cs +++ b/HrynCo.NotificationService.DAL.EF/Repositories/EmailChannelUsageRepository.cs @@ -5,7 +5,7 @@ using Microsoft.EntityFrameworkCore; namespace HrynCo.NotificationService.DAL.EF.Repositories; -internal sealed class EmailChannelUsageRepository : EfRepository, IEmailChannelUsageRepository +internal sealed class EmailChannelUsageRepository : NotificationBaseRepository, IEmailChannelUsageRepository { public EmailChannelUsageRepository(NotificationDbContext dbContext) : base(dbContext) { @@ -13,7 +13,8 @@ internal sealed class EmailChannelUsageRepository : EfRepository GetDailyCountAsync(Guid providerId, DateOnly date, CancellationToken ct = default) { - EmailChannelUsageEntity? entity = await DbSet + EmailChannelUsageEntity? entity = await EfRepository.Get() + .AsNoTracking() .FirstOrDefaultAsync(x => x.ProviderId == providerId && x.Date == date, ct); return entity?.SentCount ?? 0; @@ -21,7 +22,7 @@ internal sealed class EmailChannelUsageRepository : EfRepository GetMonthlyCountAsync(Guid providerId, int year, int month, CancellationToken ct = default) { - return await DbSet + return await EfRepository.Get() .Where(x => x.ProviderId == providerId && x.Date.Year == year && x.Date.Month == month) @@ -30,15 +31,16 @@ internal sealed class EmailChannelUsageRepository : EfRepository x.ProviderId == providerId && x.Date == date, ct); if (entity is null) - await AddAsync(new EmailChannelUsageEntity { ProviderId = providerId, Date = date, SentCount = 1 }, ct); + await EfRepository.AddAsync(new EmailChannelUsageEntity { ProviderId = providerId, Date = date, SentCount = 1 }); else { entity.SentCount++; - Update(entity); + await EfRepository.UpdateAsync(entity); } } } \ No newline at end of file diff --git a/HrynCo.NotificationService.DAL.EF/Repositories/EmailTemplateRepository.cs b/HrynCo.NotificationService.DAL.EF/Repositories/EmailTemplateRepository.cs index e3960a8..b7fe3a9 100644 --- a/HrynCo.NotificationService.DAL.EF/Repositories/EmailTemplateRepository.cs +++ b/HrynCo.NotificationService.DAL.EF/Repositories/EmailTemplateRepository.cs @@ -6,21 +6,38 @@ using Microsoft.EntityFrameworkCore; namespace HrynCo.NotificationService.DAL.EF.Repositories; -internal sealed class EmailTemplateRepository : EfRepository, IEmailTemplateRepository +internal sealed class EmailTemplateRepository + : NotificationBaseRepository, IEmailTemplateRepository { public EmailTemplateRepository(NotificationDbContext dbContext) : base(dbContext) { } - public async Task> GetAllAsync(CancellationToken ct = default) + public async Task> GetAllAsync(string? serviceName = null, string? key = null, CancellationToken ct = default) { - List entities = await DbSet.ToListAsync(ct); + IQueryable query = EfRepository.Get(); + + if (!string.IsNullOrWhiteSpace(serviceName)) + { + query = query.Where(x => x.ServiceName == serviceName); + } + + if (!string.IsNullOrWhiteSpace(key)) + { + query = query.Where(x => x.Key == key); + } + + List entities = await query + .OrderBy(x => x.ServiceName).ThenBy(x => x.Key) + .AsNoTracking() + .ToListAsync(ct); return entities.Select(MapToDomain).ToList(); } public async Task> GetByServiceAsync(string serviceName, CancellationToken ct = default) { - List entities = await DbSet + List entities = await EfRepository.Get() + .AsNoTracking() .Where(x => x.ServiceName == serviceName) .ToListAsync(ct); @@ -29,28 +46,48 @@ internal sealed class EmailTemplateRepository : EfRepository GetAsync(string serviceName, string key, string languageCode, CancellationToken ct = default) { - EmailTemplateEntity? entity = await DbSet.FirstOrDefaultAsync( - x => x.ServiceName == serviceName && x.Key == key && x.LanguageCode == languageCode, ct); + EmailTemplateEntity? entity = await EfRepository.Get() + .AsNoTracking() + .FirstOrDefaultAsync( + x => x.ServiceName == serviceName && x.Key == key && x.LanguageCode == languageCode, ct); return entity is null ? null : MapToDomain(entity); } - public Task AddAsync(EmailTemplate EmailTemplate, CancellationToken ct = default) => - base.AddAsync(MapToEntity(EmailTemplate), ct); - - public Task UpdateAsync(EmailTemplate EmailTemplate, CancellationToken ct = default) + public Task AddAsync(EmailTemplate EmailTemplate, CancellationToken ct = default) { - EmailTemplateEntity entity = MapToEntity(EmailTemplate); - entity.Updated = DateTimeOffset.UtcNow; - Update(entity); - return Task.CompletedTask; + return EfRepository.AddAsync(MapToEntity(EmailTemplate)); + } + + public async Task UpdateAsync(EmailTemplate EmailTemplate, CancellationToken ct = default) + { + EmailTemplateEntity? entity = await EfRepository.Get() + .FirstOrDefaultAsync(x => x.Id == EmailTemplate.Id, ct); + + if (entity is null) + { + return; + } + + entity.ServiceName = EmailTemplate.ServiceName; + entity.Key = EmailTemplate.Key; + entity.LanguageCode = EmailTemplate.LanguageCode; + entity.Subject = EmailTemplate.Subject; + entity.HtmlBody = EmailTemplate.HtmlBody; + entity.TextBody = EmailTemplate.TextBody; + entity.Variables = EmailTemplate.Variables + .Select(v => new EmailTemplateVariableData { Name = v.Name, Required = v.Required }) + .ToList(); + + await EfRepository.SaveChangesAsync(); } public async Task DeleteAsync(EmailTemplate EmailTemplate, CancellationToken ct = default) { - EmailTemplateEntity? entity = await DbSet.FindAsync([EmailTemplate.Id], ct); + EmailTemplateEntity? entity = await EfRepository.Get() + .FirstOrDefaultAsync(x => x.Id == EmailTemplate.Id, ct); if (entity is not null) - Delete(entity); + await EfRepository.DeleteAsync(entity); } private static EmailTemplate MapToDomain(EmailTemplateEntity e) => new() @@ -80,4 +117,4 @@ internal sealed class EmailTemplateRepository : EfRepository(options => options.UseNpgsql(connectionString)); - services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); return services; } diff --git a/HrynCo.NotificationService.Migrator/DevelopmentDataSeeder.cs b/HrynCo.NotificationService.Migrator/DevelopmentDataSeeder.cs new file mode 100644 index 0000000..0df72ba --- /dev/null +++ b/HrynCo.NotificationService.Migrator/DevelopmentDataSeeder.cs @@ -0,0 +1,116 @@ +namespace HrynCo.NotificationService.Migrator; + +using HrynCo.NotificationService.DAL.Abstract.Providers; +using HrynCo.NotificationService.DAL.Abstract.Repositories; +using HrynCo.NotificationService.DAL.Abstract.Templates; +using Microsoft.Extensions.Logging; + +internal sealed class DevelopmentDataSeeder +{ + internal const string DefaultServiceName = "TestService"; + internal const string DefaultLanguageCode = "en"; + + private readonly IEmailChannelRepository _channels; + private readonly IEmailTemplateRepository _templates; + private readonly ILogger _logger; + + public DevelopmentDataSeeder( + IEmailChannelRepository channels, + IEmailTemplateRepository templates, + ILogger logger) + { + _channels = channels; + _templates = templates; + _logger = logger; + } + + public async Task SeedAsync(string serviceName, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(serviceName)) + throw new InvalidOperationException("Development seed service name is not configured."); + + await SeedMailpitChannelAsync(serviceName, cancellationToken); + + foreach (EmailTemplate template in CreateTemplates(serviceName)) + { + EmailTemplate? existing = await _templates.GetAsync( + template.ServiceName, template.Key, template.LanguageCode, cancellationToken); + + if (existing is not null) + { + _logger.LogInformation( + "Development email template already exists; leaving it unchanged [ServiceName={ServiceName}, Key={TemplateKey}, LanguageCode={LanguageCode}]", + template.ServiceName, template.Key, template.LanguageCode); + continue; + } + + await _templates.AddAsync(template, cancellationToken); + _logger.LogInformation( + "Created development email template [ServiceName={ServiceName}, Key={TemplateKey}, LanguageCode={LanguageCode}]", + template.ServiceName, template.Key, template.LanguageCode); + } + } + + private async Task SeedMailpitChannelAsync(string serviceName, CancellationToken cancellationToken) + { + IReadOnlyList existingChannels = await _channels.GetByServiceAsync( + serviceName, cancellationToken); + + if (existingChannels.Count > 0) + { + _logger.LogInformation( + "Development email channel already exists; leaving all channels unchanged [ServiceName={ServiceName}]", + serviceName); + return; + } + + var channel = new EmailChannel + { + ServiceName = serviceName, + Priority = 1, + EmailChannelType = EmailChannelType.Smtp, + Settings = new SmtpChannelSettings + { + Host = "mailpit", + Port = 1025, + Username = string.Empty, + Password = string.Empty, + UseSsl = false, + FromEmail = "no-reply@local.hrynco.test", + FromName = "HrynCo Development" + }, + WarnThresholdPercent = 90, + IsActive = true, + Created = DateTimeOffset.UtcNow + }; + + await _channels.AddAsync(channel, cancellationToken); + _logger.LogInformation( + "Created Mailpit development email channel [ServiceName={ServiceName}]", + serviceName); + } + + private static IReadOnlyList CreateTemplates(string serviceName) + { + DateTimeOffset created = DateTimeOffset.UtcNow; + + return + [ + new EmailTemplate + { + ServiceName = serviceName, + Key = "TestEmail", + LanguageCode = DefaultLanguageCode, + Subject = "Test notification", + HtmlBody = "

Hello {{RecipientName}},

{{Message}}

", + TextBody = "Hello {{RecipientName}}, {{Message}}", + Variables = + [ + new EmailTemplateVariable { Name = "RecipientName", Required = true }, + new EmailTemplateVariable { Name = "Message", Required = true } + ], + Created = created + } + ]; + } +} diff --git a/HrynCo.NotificationService.Migrator/Program.cs b/HrynCo.NotificationService.Migrator/Program.cs index d3e979d..0fae501 100644 --- a/HrynCo.NotificationService.Migrator/Program.cs +++ b/HrynCo.NotificationService.Migrator/Program.cs @@ -1,5 +1,7 @@ -using HrynCo.NotificationService.DAL.EF; +using HrynCo.NotificationService.DAL.EF; +using HrynCo.NotificationService.Migrator; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Serilog; @@ -21,8 +23,8 @@ try var connectionString = ctx.Configuration["App:ConnectionString"] ?? throw new InvalidOperationException("App:ConnectionString is not configured."); - services.AddDbContext(options => - options.UseNpgsql(connectionString)); + services.AddNotificationDataAccess(connectionString); + services.AddScoped(); }) .Build(); @@ -32,6 +34,18 @@ try Log.Information("Applying migrations..."); await db.Database.MigrateAsync(); Log.Information("Migrations applied successfully."); + + var environment = scope.ServiceProvider.GetRequiredService(); + if (environment.IsDevelopment()) + { + string serviceName = host.Services.GetRequiredService()["DevelopmentSeed:ServiceName"] + ?? DevelopmentDataSeeder.DefaultServiceName; + + Log.Information("Seeding development email configuration..."); + var seeder = scope.ServiceProvider.GetRequiredService(); + await seeder.SeedAsync(serviceName); + Log.Information("Development email configuration is ready."); + } } catch (Exception ex) { diff --git a/HrynCo.NotificationService.Migrator/Properties/AssemblyInfo.cs b/HrynCo.NotificationService.Migrator/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..42cd9d9 --- /dev/null +++ b/HrynCo.NotificationService.Migrator/Properties/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("HrynCo.NotificationService.Services.Tests")] diff --git a/HrynCo.NotificationService.Services.Tests/DevelopmentDataSeederTests.cs b/HrynCo.NotificationService.Services.Tests/DevelopmentDataSeederTests.cs new file mode 100644 index 0000000..29e76ff --- /dev/null +++ b/HrynCo.NotificationService.Services.Tests/DevelopmentDataSeederTests.cs @@ -0,0 +1,83 @@ +namespace HrynCo.NotificationService.Services.Tests; + +using HrynCo.NotificationService.DAL.Abstract.Providers; +using HrynCo.NotificationService.DAL.Abstract.Repositories; +using HrynCo.NotificationService.DAL.Abstract.Templates; +using HrynCo.NotificationService.Migrator; +using Microsoft.Extensions.Logging.Abstractions; +using NSubstitute; + +public sealed class DevelopmentDataSeederTests +{ + private readonly IEmailChannelRepository _channels = Substitute.For(); + private readonly IEmailTemplateRepository _templates = Substitute.For(); + + [Fact] + public async Task SeedAsync_WhenConfigurationIsMissing_CreatesMailpitChannelAndTestTemplate() + { + _channels.GetByServiceAsync("TestService", Arg.Any()) + .Returns(Array.Empty()); + _templates.GetAsync("TestService", "TestEmail", "en", Arg.Any()) + .Returns((EmailTemplate?)null); + + DevelopmentDataSeeder seeder = CreateSeeder(); + + await seeder.SeedAsync("TestService", CancellationToken.None); + + await _channels.Received(1).AddAsync( + Arg.Is(channel => IsMailpitChannel(channel)), + Arg.Any()); + await _templates.Received(1).AddAsync( + Arg.Is(template => + template.ServiceName == "TestService" && + template.Key == "TestEmail" && + template.Variables.Any(variable => variable.Name == "Message" && variable.Required)), + Arg.Any()); + } + + [Fact] + public async Task SeedAsync_WhenConfigurationExists_DoesNotDuplicateOrOverwriteIt() + { + _channels.GetByServiceAsync("TestService", Arg.Any()) + .Returns([new EmailChannel + { + ServiceName = "TestService", + EmailChannelType = EmailChannelType.Smtp, + Settings = new SmtpChannelSettings() + }]); + _templates.GetAsync("TestService", "TestEmail", "en", Arg.Any()) + .Returns(new EmailTemplate + { + ServiceName = "TestService", + Key = "TestEmail", + LanguageCode = "en", + Subject = "Existing", + HtmlBody = "Existing", + TextBody = "Existing" + }); + + DevelopmentDataSeeder seeder = CreateSeeder(); + + await seeder.SeedAsync("TestService", CancellationToken.None); + + await _channels.DidNotReceive().AddAsync(Arg.Any(), Arg.Any()); + await _templates.DidNotReceive().AddAsync(Arg.Any(), Arg.Any()); + await _channels.DidNotReceive().UpdateAsync(Arg.Any(), Arg.Any()); + await _templates.DidNotReceive().UpdateAsync(Arg.Any(), Arg.Any()); + } + + private DevelopmentDataSeeder CreateSeeder() => new( + _channels, + _templates, + NullLogger.Instance); + + private static bool IsMailpitChannel(EmailChannel channel) + { + return channel.ServiceName == "TestService" && + channel.IsActive && + channel.Settings is SmtpChannelSettings smtp && + smtp.Host == "mailpit" && + smtp.Port == 1025 && + !smtp.UseSsl; + } +} diff --git a/HrynCo.NotificationService.Services.Tests/EmailProcessing/EmailTemplateRenderingServiceTests.cs b/HrynCo.NotificationService.Services.Tests/EmailProcessing/EmailTemplateRenderingServiceTests.cs new file mode 100644 index 0000000..cca8669 --- /dev/null +++ b/HrynCo.NotificationService.Services.Tests/EmailProcessing/EmailTemplateRenderingServiceTests.cs @@ -0,0 +1,88 @@ +namespace HrynCo.NotificationService.Services.Tests.EmailProcessing; + +using HrynCo.NotificationService.Contracts.Messages; +using HrynCo.NotificationService.DAL.Abstract.Templates; +using HrynCo.NotificationService.Worker.Services.EmailProcessing; + +public sealed class EmailTemplateRenderingServiceTests +{ + private readonly EmailTemplateRenderingService _service = new(); + + [Fact] + public void Render_InterpolatesItemTrackerVariables() + { + EmailTemplate template = CreateTemplate(); + SendEmailMessageData data = CreateData(new Dictionary + { + ["AppName"] = "StoreMate", + ["VerificationUrl"] = "https://example.invalid/verify" + }); + + RenderedEmail result = _service.Render(template, data); + + Assert.Equal("Verify StoreMate", result.Subject); + Assert.Equal("Verify", result.HtmlBody); + Assert.Equal("Verify at https://example.invalid/verify", result.TextBody); + } + + [Fact] + public void Render_WhenRequiredVariableIsMissing_ThrowsObservableFailure() + { + EmailTemplate template = CreateTemplate(); + SendEmailMessageData data = CreateData(new Dictionary + { + ["AppName"] = "StoreMate" + }); + + InvalidDataException exception = Assert.Throws( + () => _service.Render(template, data)); + + Assert.Equal("Required template variables are missing: VerificationUrl.", exception.Message); + } + + [Fact] + public void Render_HtmlEncodesVariablesWithoutChangingSubjectOrTextBody() + { + EmailTemplate template = CreateTemplate(); + SendEmailMessageData data = CreateData(new Dictionary + { + ["AppName"] = "Invemory ", + ["VerificationUrl"] = "https://example.invalid/verify?next=\" onclick=\"alert('xss')" + }); + + RenderedEmail result = _service.Render(template, data); + + Assert.Equal("Verify Invemory ", result.Subject); + Assert.Equal( + "Verify", + result.HtmlBody); + Assert.Equal( + "Verify at https://example.invalid/verify?next=\" onclick=\"alert('xss')", + result.TextBody); + } + + private static EmailTemplate CreateTemplate() => new() + { + ServiceName = "StoreMate-Prod", + Key = "EmailVerification", + LanguageCode = "uk", + Subject = "Verify {{AppName}}", + HtmlBody = "Verify", + TextBody = "Verify at {{VerificationUrl}}", + Variables = + [ + new EmailTemplateVariable { Name = "AppName", Required = true }, + new EmailTemplateVariable { Name = "VerificationUrl", Required = true } + ] + }; + + private static SendEmailMessageData CreateData(IReadOnlyDictionary variables) => new() + { + ServiceName = "StoreMate-Prod", + TemplateKey = "EmailVerification", + RecipientEmail = "owner@example.com", + RecipientName = "Owner", + LanguageCode = "uk", + Variables = variables + }; +} diff --git a/HrynCo.NotificationService.Services.Tests/EmailProcessing/EmailTemplateServiceTests.cs b/HrynCo.NotificationService.Services.Tests/EmailProcessing/EmailTemplateServiceTests.cs new file mode 100644 index 0000000..b22b9bc --- /dev/null +++ b/HrynCo.NotificationService.Services.Tests/EmailProcessing/EmailTemplateServiceTests.cs @@ -0,0 +1,30 @@ +namespace HrynCo.NotificationService.Services.Tests.EmailProcessing; + +using HrynCo.NotificationService.DAL.Abstract.Repositories; +using HrynCo.NotificationService.Worker.Services.EmailProcessing; +using NSubstitute; + +public sealed class EmailTemplateServiceTests +{ + [Fact] + public async Task GetAsync_WhenRequestedLanguageDoesNotExist_DoesNotSilentlyFallback() + { + IEmailTemplateRepository repository = Substitute.For(); + var service = new EmailTemplateService(repository); + + InvalidOperationException exception = await Assert.ThrowsAsync( + () => service.GetAsync("StoreMate-Prod", "EmailVerification", "UK", CancellationToken.None)); + + Assert.Contains("language='uk'", exception.Message); + await repository.Received(1).GetAsync( + "StoreMate-Prod", + "EmailVerification", + "uk", + CancellationToken.None); + await repository.DidNotReceive().GetAsync( + "StoreMate-Prod", + "EmailVerification", + "en", + Arg.Any()); + } +} diff --git a/HrynCo.NotificationService.Services.Tests/EmailProcessing/NotificationDeliveryErrorFormatterTests.cs b/HrynCo.NotificationService.Services.Tests/EmailProcessing/NotificationDeliveryErrorFormatterTests.cs new file mode 100644 index 0000000..35f9bf3 --- /dev/null +++ b/HrynCo.NotificationService.Services.Tests/EmailProcessing/NotificationDeliveryErrorFormatterTests.cs @@ -0,0 +1,58 @@ +namespace HrynCo.NotificationService.Services.Tests.EmailProcessing; + +using HrynCo.NotificationService.Worker.Services.EmailProcessing; +using System.Net.Mail; +using System.Net.Sockets; + +public sealed class NotificationDeliveryErrorFormatterTests +{ + [Fact] + public void Format_UsesInnermostMessageAndRemovesLineBreaks() + { + var exception = new InvalidOperationException( + "outer", + new Exception("provider failed\r\nretry rejected")); + + string result = NotificationDeliveryErrorFormatter.Format(exception); + + Assert.Equal( + "Notification delivery failed after all retry attempts (provider failed retry rejected).", + result); + } + + [Fact] + public void Format_UnresolvedSmtpHost_AddsSafeChannelContext() + { + var socketException = new SocketException((int)SocketError.HostNotFound); + var exception = new SmtpException("Failure sending mail.", socketException); + + string result = NotificationDeliveryErrorFormatter.Format(exception); + + Assert.Equal( + $"SMTP delivery failed after all retry attempts: the configured SMTP server host could not be resolved ({socketException.Message}).", + result); + } + + [Fact] + public void Format_RefusedSmtpConnection_AddsSafeChannelContext() + { + var socketException = new SocketException((int)SocketError.ConnectionRefused); + var exception = new SmtpException("Failure sending mail.", socketException); + + string result = NotificationDeliveryErrorFormatter.Format(exception); + + Assert.Equal( + $"SMTP delivery failed after all retry attempts: the configured SMTP server refused the connection ({socketException.Message}).", + result); + } + + [Fact] + public void Format_LongError_IsBoundedToOutboxColumnLength() + { + var exception = new Exception(new string('x', 2100)); + + string result = NotificationDeliveryErrorFormatter.Format(exception); + + Assert.Equal(2000, result.Length); + } +} diff --git a/HrynCo.NotificationService.Services.Tests/EmailProcessing/NotificationResultPublisherTests.cs b/HrynCo.NotificationService.Services.Tests/EmailProcessing/NotificationResultPublisherTests.cs new file mode 100644 index 0000000..6b4e41e --- /dev/null +++ b/HrynCo.NotificationService.Services.Tests/EmailProcessing/NotificationResultPublisherTests.cs @@ -0,0 +1,107 @@ +namespace HrynCo.NotificationService.Services.Tests.EmailProcessing; + +using HrynCo.NotificationService.Contracts.Messages; +using HrynCo.NotificationService.Worker.Services.EmailProcessing; +using Hrynco.RabbitMq; +using Microsoft.Extensions.Logging.Abstractions; +using NSubstitute; + +public sealed class NotificationResultPublisherTests +{ + [Fact] + public async Task PublishAsync_TerminalFailure_PublishesNeutralFailureResultToReplyQueue() + { + IRabbitMqPublisher rabbitMqPublisher = Substitute.For(); + var publisher = new NotificationResultPublisher( + rabbitMqPublisher, + NullLogger.Instance); + SendEmailMessage message = CreateMessage(); + NotificationResultMessage? publishedResult = null; + rabbitMqPublisher + .When(x => x.PublishAsync( + "item-tracker.notifications.result", + Arg.Any(), + Arg.Any())) + .Do(call => publishedResult = call.ArgAt(1)); + + await publisher.PublishAsync( + message, + "Email provider could not deliver the notification.", + CancellationToken.None); + + await rabbitMqPublisher.Received(1).PublishAsync( + "item-tracker.notifications.result", + Arg.Any(), + CancellationToken.None); + Assert.NotNull(publishedResult); + Assert.Equal(message.CorrelationContext.CorrelationId, publishedResult.CorrelationContext.CorrelationId); + Assert.Null(publishedResult.CorrelationContext.ReplyTo); + Assert.Equal(message.Data.ServiceName, publishedResult.Data.ServiceName); + Assert.Equal(message.Data.TemplateKey, publishedResult.Data.TemplateKey); + Assert.Equal(message.Data.RecipientEmail, publishedResult.Data.RecipientEmail); + Assert.Equal("Email provider could not deliver the notification.", publishedResult.Data.ErrorMessage); + Assert.False(publishedResult.Data.IsSuccess); + } + + [Fact] + public async Task PublishAsync_NoReplyQueue_DoesNotPublish() + { + IRabbitMqPublisher rabbitMqPublisher = Substitute.For(); + var publisher = new NotificationResultPublisher( + rabbitMqPublisher, + NullLogger.Instance); + SendEmailMessage message = CreateMessage(); + message.CorrelationContext = message.CorrelationContext with { ReplyTo = null }; + + await publisher.PublishAsync(message, "delivery failed", CancellationToken.None); + + await rabbitMqPublisher.DidNotReceive() + .PublishAsync( + Arg.Any(), + Arg.Any(), + Arg.Any()); + } + + [Fact] + public async Task PublishAsync_ResultBrokerFailure_IsBestEffort() + { + IRabbitMqPublisher rabbitMqPublisher = Substitute.For(); + rabbitMqPublisher + .PublishAsync( + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(_ => throw new InvalidOperationException("reply broker unavailable")); + var publisher = new NotificationResultPublisher( + rabbitMqPublisher, + NullLogger.Instance); + + Exception? exception = await Record.ExceptionAsync(() => publisher.PublishAsync( + CreateMessage(), + "delivery failed", + CancellationToken.None)); + + Assert.Null(exception); + } + + private static SendEmailMessage CreateMessage() + { + return new SendEmailMessage + { + CorrelationContext = new CorrelationContext + { + CorrelationId = "correlation-id", + ReplyTo = "item-tracker.notifications.result" + }, + Data = new SendEmailMessageData + { + ServiceName = "TestService", + TemplateKey = "TestEmail", + RecipientEmail = "test.user@itemtracker.local", + RecipientName = "Test User", + LanguageCode = "en", + Variables = new Dictionary() + } + }; + } +} diff --git a/HrynCo.NotificationService.Services.Tests/EmailProcessing/RecipientAddressRedactorTests.cs b/HrynCo.NotificationService.Services.Tests/EmailProcessing/RecipientAddressRedactorTests.cs new file mode 100644 index 0000000..3ff9991 --- /dev/null +++ b/HrynCo.NotificationService.Services.Tests/EmailProcessing/RecipientAddressRedactorTests.cs @@ -0,0 +1,16 @@ +namespace HrynCo.NotificationService.Services.Tests.EmailProcessing; + +using HrynCo.NotificationService.Worker.Services.EmailProcessing; + +public sealed class RecipientAddressRedactorTests +{ + [Theory] + [InlineData("owner@example.com", "o***@e***.com")] + [InlineData("a@b.test", "a***@b***.test")] + [InlineData("invalid", "***")] + [InlineData(null, "")] + public void Redact_DoesNotExposeFullAddress(string? address, string expected) + { + Assert.Equal(expected, RecipientAddressRedactor.Redact(address)); + } +} diff --git a/HrynCo.NotificationService.Services.Tests/EmailProcessing/SendEmailContractTests.cs b/HrynCo.NotificationService.Services.Tests/EmailProcessing/SendEmailContractTests.cs new file mode 100644 index 0000000..8bc91d6 --- /dev/null +++ b/HrynCo.NotificationService.Services.Tests/EmailProcessing/SendEmailContractTests.cs @@ -0,0 +1,60 @@ +namespace HrynCo.NotificationService.Services.Tests.EmailProcessing; + +using System.Text.Json; +using HrynCo.NotificationService.Contracts.Messages; +using HrynCo.NotificationService.Worker.Services.EmailProcessing; + +public sealed class SendEmailContractTests +{ + private const string ItemTrackerPayload = """ + { + "CorrelationContext": { + "CorrelationId": "3ec4ad3f-0a8a-49f3-b31d-cdad5d1b95cc", + "ReplyTo": "item-tracker.notifications.result" + }, + "Data": { + "ServiceName": "StoreMate-Prod", + "TemplateKey": "EmailVerification", + "RecipientEmail": "owner@example.com", + "RecipientName": "StoreMate Owner", + "Variables": { + "AppName": "StoreMate", + "VerificationUrl": "https://example.invalid/sensitive-token" + }, + "LanguageCode": "uk" + } + } + """; + + [Fact] + public void PascalCaseItemTrackerPayload_DeserializesWithoutLosingValues() + { + SendEmailMessage? message = JsonSerializer.Deserialize(ItemTrackerPayload); + + Assert.NotNull(message); + SendEmailMessageValidator.Validate(message); + Assert.Equal("3ec4ad3f-0a8a-49f3-b31d-cdad5d1b95cc", message.CorrelationContext.CorrelationId); + Assert.Equal("item-tracker.notifications.result", message.CorrelationContext.ReplyTo); + Assert.Equal("StoreMate-Prod", message.Data.ServiceName); + Assert.Equal("EmailVerification", message.Data.TemplateKey); + Assert.Equal("owner@example.com", message.Data.RecipientEmail); + Assert.Equal("StoreMate Owner", message.Data.RecipientName); + Assert.Equal("uk", message.Data.LanguageCode); + Assert.Equal("StoreMate", message.Data.Variables["AppName"]); + Assert.Equal( + "https://example.invalid/sensitive-token", + message.Data.Variables["VerificationUrl"]); + } + + [Fact] + public void MissingLanguageCode_IsRejected() + { + SendEmailMessage? message = JsonSerializer.Deserialize( + ItemTrackerPayload.Replace("\"uk\"", "null", StringComparison.Ordinal)); + + InvalidDataException exception = Assert.Throws( + () => SendEmailMessageValidator.Validate(message!)); + + Assert.Equal("LanguageCode is required.", exception.Message); + } +} diff --git a/HrynCo.NotificationService.Services.Tests/EmailProcessing/SendEmailDeliveryValidatorTests.cs b/HrynCo.NotificationService.Services.Tests/EmailProcessing/SendEmailDeliveryValidatorTests.cs new file mode 100644 index 0000000..c545e03 --- /dev/null +++ b/HrynCo.NotificationService.Services.Tests/EmailProcessing/SendEmailDeliveryValidatorTests.cs @@ -0,0 +1,77 @@ +namespace HrynCo.NotificationService.Services.Tests.EmailProcessing; + +using HrynCo.NotificationService.Contracts.Messages; +using HrynCo.NotificationService.Worker; +using Hrynco.RabbitMq; + +public sealed class SendEmailDeliveryValidatorTests +{ + [Fact] + public void ValidItemTrackerDelivery_IsAccepted() + { + string? error = SendEmailDeliveryValidator.GetValidationError( + CreateMessage(), + CreateContext(), + SendEmailConsumer.SupportedMessageType); + + Assert.Null(error); + } + + [Fact] + public void MissingMessageId_IsRejected() + { + RabbitMqMessageContext context = CreateContext() with { MessageId = null }; + + string? error = SendEmailDeliveryValidator.GetValidationError( + CreateMessage(), + context, + SendEmailConsumer.SupportedMessageType); + + Assert.Equal("AMQP MessageId is required.", error); + } + + [Fact] + public void UnsupportedContractVersion_IsRejected() + { + RabbitMqMessageContext context = CreateContext() with + { + MessageType = "Notification.SendEmail.v2" + }; + + string? error = SendEmailDeliveryValidator.GetValidationError( + CreateMessage(), + context, + SendEmailConsumer.SupportedMessageType); + + Assert.Equal("Unsupported AMQP message type 'Notification.SendEmail.v2'.", error); + } + + private static RabbitMqMessageContext CreateContext() => new() + { + QueueName = SendEmailConsumer.IncomingQueue, + Exchange = string.Empty, + RoutingKey = SendEmailConsumer.IncomingQueue, + MessageId = Guid.NewGuid().ToString("D"), + MessageType = SendEmailConsumer.SupportedMessageType, + CorrelationId = "correlation-id", + ContentType = "application/json" + }; + + private static SendEmailMessage CreateMessage() => new() + { + CorrelationContext = new CorrelationContext + { + CorrelationId = "correlation-id", + ReplyTo = "item-tracker.notifications.result" + }, + Data = new SendEmailMessageData + { + ServiceName = "StoreMate-Prod", + TemplateKey = "EmailVerification", + RecipientEmail = "owner@example.com", + RecipientName = "Owner", + LanguageCode = "uk", + Variables = new Dictionary { ["AppName"] = "StoreMate" } + } + }; +} diff --git a/HrynCo.NotificationService.Services.Tests/EmailProcessing/SendEmailServiceTests.cs b/HrynCo.NotificationService.Services.Tests/EmailProcessing/SendEmailServiceTests.cs new file mode 100644 index 0000000..cffb53d --- /dev/null +++ b/HrynCo.NotificationService.Services.Tests/EmailProcessing/SendEmailServiceTests.cs @@ -0,0 +1,123 @@ +namespace HrynCo.NotificationService.Services.Tests.EmailProcessing; + +using HrynCo.NotificationService.Contracts.Messages; +using HrynCo.NotificationService.DAL.Abstract.Providers; +using HrynCo.NotificationService.DAL.Abstract.Repositories; +using HrynCo.NotificationService.DAL.Abstract.Templates; +using HrynCo.NotificationService.Worker.Services.EmailProcessing; +using Hrynco.RabbitMq; +using Microsoft.Extensions.Logging.Abstractions; +using NSubstitute; + +public sealed class SendEmailServiceTests +{ + [Fact] + public async Task ProcessAsync_ValidItemTrackerMessage_SendsOnceAndUpdatesUsage() + { + Guid channelId = Guid.NewGuid(); + var settings = new SmtpChannelSettings + { + Host = "smtp.example.invalid", + Port = 587, + Username = "smtp-user", + Password = "not-a-real-secret", + UseSsl = true, + FromEmail = "notifications@example.com", + FromName = "StoreMate" + }; + var channel = new EmailChannel + { + Id = channelId, + ServiceName = "StoreMate-Prod", + Priority = 1, + EmailChannelType = EmailChannelType.Smtp, + Settings = settings, + IsActive = true + }; + var template = new EmailTemplate + { + ServiceName = "StoreMate-Prod", + Key = "EmailVerification", + LanguageCode = "uk", + Subject = "Verify {{AppName}}", + HtmlBody = "

{{AppName}}

", + TextBody = "{{AppName}}", + Variables = [new EmailTemplateVariable { Name = "AppName", Required = true }] + }; + + IEmailChannelRepository channels = Substitute.For(); + channels.GetByServiceAsync("StoreMate-Prod", Arg.Any()) + .Returns([channel]); + IEmailChannelUsageRepository usage = Substitute.For(); + IEmailTemplateService templates = Substitute.For(); + templates.GetAsync("StoreMate-Prod", "EmailVerification", "uk", Arg.Any()) + .Returns(template); + var renderer = new EmailTemplateRenderingService(); + var smtp = new RecordingSmtpEmailSender(); + INotificationResultPublisher resultPublisher = Substitute.For(); + var service = new SendEmailService( + channels, + usage, + templates, + renderer, + smtp, + resultPublisher, + NullLogger.Instance); + var message = new SendEmailMessage + { + CorrelationContext = new CorrelationContext + { + CorrelationId = Guid.NewGuid().ToString("D") + }, + Data = new SendEmailMessageData + { + ServiceName = "StoreMate-Prod", + TemplateKey = "EmailVerification", + RecipientEmail = "owner@example.com", + RecipientName = "Owner", + LanguageCode = "uk", + Variables = new Dictionary { ["AppName"] = "StoreMate" } + } + }; + + await service.ProcessAsync(message, CancellationToken.None); + + Assert.Equal(1, smtp.SendCount); + Assert.Same(settings, smtp.Settings); + Assert.Equal("Verify StoreMate", smtp.Email?.Subject); + Assert.Equal("owner@example.com", smtp.RecipientEmail); + Assert.Equal("Owner", smtp.RecipientName); + await usage.Received(1).IncrementUsageAsync( + channelId, + Arg.Any(), + CancellationToken.None); + await resultPublisher.Received(1).PublishAsync( + message, + null, + CancellationToken.None); + } + + private sealed class RecordingSmtpEmailSender : ISmtpEmailSender + { + public int SendCount { get; private set; } + public SmtpChannelSettings? Settings { get; private set; } + public RenderedEmail? Email { get; private set; } + public string? RecipientEmail { get; private set; } + public string? RecipientName { get; private set; } + + public Task SendAsync( + SmtpChannelSettings settings, + RenderedEmail email, + string recipientEmail, + string recipientName, + CancellationToken cancellationToken) + { + SendCount++; + Settings = settings; + Email = email; + RecipientEmail = recipientEmail; + RecipientName = recipientName; + return Task.CompletedTask; + } + } +} diff --git a/HrynCo.NotificationService.Services.Tests/HrynCo.NotificationService.Services.Tests.csproj b/HrynCo.NotificationService.Services.Tests/HrynCo.NotificationService.Services.Tests.csproj index 69e51f0..4b94123 100644 --- a/HrynCo.NotificationService.Services.Tests/HrynCo.NotificationService.Services.Tests.csproj +++ b/HrynCo.NotificationService.Services.Tests/HrynCo.NotificationService.Services.Tests.csproj @@ -10,6 +10,7 @@ + @@ -21,6 +22,10 @@ + + + + - \ No newline at end of file + diff --git a/HrynCo.NotificationService.Services.Tests/UnitTest1.cs b/HrynCo.NotificationService.Services.Tests/UnitTest1.cs deleted file mode 100644 index 3c7bcd3..0000000 --- a/HrynCo.NotificationService.Services.Tests/UnitTest1.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace HrynCo.NotificationService.Services.Tests; - -public class UnitTest1 -{ - [Fact] - public void Test1() - { - - } -} diff --git a/HrynCo.NotificationService.Services/Behaviors/TransactionBehavior.cs b/HrynCo.NotificationService.Services/Behaviors/TransactionBehavior.cs index db7592a..87290a3 100644 --- a/HrynCo.NotificationService.Services/Behaviors/TransactionBehavior.cs +++ b/HrynCo.NotificationService.Services/Behaviors/TransactionBehavior.cs @@ -1,5 +1,5 @@ using HrynCo.Common; -using HrynCo.NotificationService.DAL.Abstract; +using HrynCo.DAL.Abstract; using MediatR; namespace HrynCo.NotificationService.Services.Behaviors; @@ -18,11 +18,15 @@ public class TransactionBehavior : IPipelineBehavior Handle(TRequest request, RequestHandlerDelegate next, CancellationToken cancellationToken) => _profiler.MeasureExecutionAsync( - () => _unitOfWork.ExecuteInTransactionAsync(async () => + async () => { - TResponse response = await next(); - await _unitOfWork.SaveChangesAsync(cancellationToken); - return response; - }), + TResponse? response = default; + await _unitOfWork.ExecuteInTransactionAsync(async () => + { + response = await next(); + }); + + return response!; + }, typeof(TRequest).Name); -} \ No newline at end of file +} diff --git a/HrynCo.NotificationService.Services/Core/RequestHandler.cs b/HrynCo.NotificationService.Services/Core/RequestHandler.cs index b40264e..54e7c2c 100644 --- a/HrynCo.NotificationService.Services/Core/RequestHandler.cs +++ b/HrynCo.NotificationService.Services/Core/RequestHandler.cs @@ -1,4 +1,3 @@ -using HrynCo.NotificationService.DAL.Abstract; using HrynCo.NotificationService.Services.Logging; using MediatR; using Serilog; @@ -8,14 +7,12 @@ namespace HrynCo.NotificationService.Services.Core; public abstract class RequestHandler : IRequestHandler where TRequest : IRequest { - protected RequestHandler(IContextualSerilogLogger logger, IUnitOfWork unitOfWork) + protected RequestHandler(IContextualSerilogLogger logger) { Logger = logger.Logger; - UnitOfWork = unitOfWork; } protected ILogger Logger { get; } - protected IUnitOfWork UnitOfWork { get; } public Task Handle(TRequest request, CancellationToken cancellationToken) { @@ -23,4 +20,4 @@ public abstract class RequestHandler : IRequestHandler DoOnHandle(TRequest request, CancellationToken cancellationToken); -} \ No newline at end of file +} diff --git a/HrynCo.NotificationService.Services/EmailChannels/Create/CreateEmailChannelHandler.cs b/HrynCo.NotificationService.Services/EmailChannels/Create/CreateEmailChannelHandler.cs index 7538abe..ef3ff21 100644 --- a/HrynCo.NotificationService.Services/EmailChannels/Create/CreateEmailChannelHandler.cs +++ b/HrynCo.NotificationService.Services/EmailChannels/Create/CreateEmailChannelHandler.cs @@ -1,4 +1,4 @@ -using HrynCo.NotificationService.DAL.Abstract; +using HrynCo.DAL.Abstract; using HrynCo.NotificationService.DAL.Abstract.Providers; using HrynCo.NotificationService.DAL.Abstract.Repositories; using HrynCo.NotificationService.Services.Core; @@ -14,9 +14,8 @@ internal sealed class CreateEmailChannelHandler public CreateEmailChannelHandler( IContextualSerilogLogger logger, - IUnitOfWork unitOfWork, IEmailChannelRepository channels) - : base(logger, unitOfWork) + : base(logger) { _channels = channels; } diff --git a/HrynCo.NotificationService.Services/EmailChannels/Delete/DeleteEmailChannelHandler.cs b/HrynCo.NotificationService.Services/EmailChannels/Delete/DeleteEmailChannelHandler.cs index 9d126cd..c1d64a9 100644 --- a/HrynCo.NotificationService.Services/EmailChannels/Delete/DeleteEmailChannelHandler.cs +++ b/HrynCo.NotificationService.Services/EmailChannels/Delete/DeleteEmailChannelHandler.cs @@ -1,4 +1,4 @@ -using HrynCo.NotificationService.DAL.Abstract; +using HrynCo.DAL.Abstract; using HrynCo.NotificationService.DAL.Abstract.Repositories; using HrynCo.NotificationService.Services.Core; using HrynCo.NotificationService.Services.Logging; @@ -13,9 +13,8 @@ internal sealed class DeleteEmailChannelHandler public DeleteEmailChannelHandler( IContextualSerilogLogger logger, - IUnitOfWork unitOfWork, IEmailChannelRepository channels) - : base(logger, unitOfWork) + : base(logger) { _channels = channels; } diff --git a/HrynCo.NotificationService.Services/EmailChannels/Get/GetEmailChannelHandler.cs b/HrynCo.NotificationService.Services/EmailChannels/Get/GetEmailChannelHandler.cs index a85d45d..867fd2d 100644 --- a/HrynCo.NotificationService.Services/EmailChannels/Get/GetEmailChannelHandler.cs +++ b/HrynCo.NotificationService.Services/EmailChannels/Get/GetEmailChannelHandler.cs @@ -1,4 +1,3 @@ -using HrynCo.NotificationService.DAL.Abstract; using HrynCo.NotificationService.DAL.Abstract.Providers; using HrynCo.NotificationService.DAL.Abstract.Repositories; using HrynCo.NotificationService.Services.Core; @@ -14,9 +13,8 @@ internal sealed class GetEmailChannelHandler public GetEmailChannelHandler( IContextualSerilogLogger logger, - IUnitOfWork unitOfWork, IEmailChannelRepository channels) - : base(logger, unitOfWork) + : base(logger) { _channels = channels; } diff --git a/HrynCo.NotificationService.Services/EmailChannels/GetAll/GetAllEmailChannelsHandler.cs b/HrynCo.NotificationService.Services/EmailChannels/GetAll/GetAllEmailChannelsHandler.cs index 91299ef..51cc638 100644 --- a/HrynCo.NotificationService.Services/EmailChannels/GetAll/GetAllEmailChannelsHandler.cs +++ b/HrynCo.NotificationService.Services/EmailChannels/GetAll/GetAllEmailChannelsHandler.cs @@ -1,4 +1,3 @@ -using HrynCo.NotificationService.DAL.Abstract; using HrynCo.NotificationService.DAL.Abstract.Providers; using HrynCo.NotificationService.DAL.Abstract.Repositories; using HrynCo.NotificationService.Services.Core; @@ -14,9 +13,8 @@ internal sealed class GetAllEmailChannelsHandler public GetAllEmailChannelsHandler( IContextualSerilogLogger logger, - IUnitOfWork unitOfWork, IEmailChannelRepository channels) - : base(logger, unitOfWork) + : base(logger) { _channels = channels; } diff --git a/HrynCo.NotificationService.Services/EmailChannels/GetByService/GetEmailChannelsHandler.cs b/HrynCo.NotificationService.Services/EmailChannels/GetByService/GetEmailChannelsHandler.cs index b8af894..e2dc157 100644 --- a/HrynCo.NotificationService.Services/EmailChannels/GetByService/GetEmailChannelsHandler.cs +++ b/HrynCo.NotificationService.Services/EmailChannels/GetByService/GetEmailChannelsHandler.cs @@ -1,4 +1,3 @@ -using HrynCo.NotificationService.DAL.Abstract; using HrynCo.NotificationService.DAL.Abstract.Providers; using HrynCo.NotificationService.DAL.Abstract.Repositories; using HrynCo.NotificationService.Services.Core; @@ -14,9 +13,8 @@ internal sealed class GetEmailChannelsHandler public GetEmailChannelsHandler( IContextualSerilogLogger logger, - IUnitOfWork unitOfWork, IEmailChannelRepository channels) - : base(logger, unitOfWork) + : base(logger) { _channels = channels; } diff --git a/HrynCo.NotificationService.Services/EmailChannels/GetUsageSummary/GetChannelUsageSummaryHandler.cs b/HrynCo.NotificationService.Services/EmailChannels/GetUsageSummary/GetChannelUsageSummaryHandler.cs index e9dc616..57c76d8 100644 --- a/HrynCo.NotificationService.Services/EmailChannels/GetUsageSummary/GetChannelUsageSummaryHandler.cs +++ b/HrynCo.NotificationService.Services/EmailChannels/GetUsageSummary/GetChannelUsageSummaryHandler.cs @@ -1,4 +1,3 @@ -using HrynCo.NotificationService.DAL.Abstract; using HrynCo.NotificationService.DAL.Abstract.Repositories; using HrynCo.NotificationService.Services.Core; using HrynCo.NotificationService.Services.Logging; @@ -13,9 +12,8 @@ internal sealed class GetChannelUsageSummaryHandler public GetChannelUsageSummaryHandler( IContextualSerilogLogger logger, - IUnitOfWork unitOfWork, IEmailChannelRepository channelsRepository) - : base(logger, unitOfWork) + : base(logger) { _channelsRepository = channelsRepository; } diff --git a/HrynCo.NotificationService.Services/EmailChannels/Send/SendEmailHandler.cs b/HrynCo.NotificationService.Services/EmailChannels/Send/SendEmailHandler.cs index 8b6020b..1d0426b 100644 --- a/HrynCo.NotificationService.Services/EmailChannels/Send/SendEmailHandler.cs +++ b/HrynCo.NotificationService.Services/EmailChannels/Send/SendEmailHandler.cs @@ -1,6 +1,6 @@ using System.Net; using System.Net.Mail; -using HrynCo.NotificationService.DAL.Abstract; +using System.Text; using HrynCo.NotificationService.DAL.Abstract.Providers; using HrynCo.NotificationService.DAL.Abstract.Repositories; using HrynCo.NotificationService.Services.Core; @@ -17,10 +17,9 @@ internal sealed class SendEmailHandler public SendEmailHandler( IContextualSerilogLogger logger, - IUnitOfWork unitOfWork, IEmailChannelRepository channels, IEmailChannelUsageRepository usage) - : base(logger, unitOfWork) + : base(logger) { _channels = channels; _usage = usage; @@ -50,14 +49,16 @@ internal sealed class SendEmailHandler { From = new MailAddress(smtp.FromEmail, smtp.FromName), Subject = request.Subject, - Body = request.HtmlBody, - IsBodyHtml = true + Body = request.TextBody ?? string.Empty, + IsBodyHtml = false, + BodyEncoding = Encoding.UTF8, + SubjectEncoding = Encoding.UTF8 }; - if (!string.IsNullOrWhiteSpace(request.TextBody)) + if (!string.IsNullOrWhiteSpace(request.HtmlBody)) { - var plain = AlternateView.CreateAlternateViewFromString(request.TextBody, null, "text/plain"); - mail.AlternateViews.Add(plain); + var html = AlternateView.CreateAlternateViewFromString(request.HtmlBody, Encoding.UTF8, "text/html"); + mail.AlternateViews.Add(html); } mail.To.Add(new MailAddress(request.RecipientEmail, request.RecipientName)); diff --git a/HrynCo.NotificationService.Services/EmailChannels/TestSmtp/TestSmtpCommand.cs b/HrynCo.NotificationService.Services/EmailChannels/TestSmtp/TestSmtpCommand.cs new file mode 100644 index 0000000..acd5b2a --- /dev/null +++ b/HrynCo.NotificationService.Services/EmailChannels/TestSmtp/TestSmtpCommand.cs @@ -0,0 +1,18 @@ +using HrynCo.NotificationService.Services.Core; +using MediatR; + +namespace HrynCo.NotificationService.Services.EmailChannels.TestSmtp; + +/// +/// Sends a test email using the provided SMTP settings without persisting anything. +/// +public sealed record TestSmtpCommand( + string Host, + int Port, + string Username, + string Password, + bool UseSsl, + string FromEmail, + string FromName, + string ToEmail +) : IRequest>; diff --git a/HrynCo.NotificationService.Services/EmailChannels/TestSmtp/TestSmtpHandler.cs b/HrynCo.NotificationService.Services/EmailChannels/TestSmtp/TestSmtpHandler.cs new file mode 100644 index 0000000..573a9ee --- /dev/null +++ b/HrynCo.NotificationService.Services/EmailChannels/TestSmtp/TestSmtpHandler.cs @@ -0,0 +1,50 @@ +using System.Net; +using System.Net.Mail; +using HrynCo.NotificationService.Services.Core; +using HrynCo.NotificationService.Services.Logging; +using static HrynCo.NotificationService.Services.Core.ServiceResultHelper; + +namespace HrynCo.NotificationService.Services.EmailChannels.TestSmtp; + +internal sealed class TestSmtpHandler + : RequestHandler> +{ + public TestSmtpHandler( + IContextualSerilogLogger logger) + : base(logger) + { + } + + protected override async Task> DoOnHandle( + TestSmtpCommand request, CancellationToken cancellationToken) + { + try + { + using var client = new SmtpClient(request.Host, request.Port) + { + EnableSsl = request.UseSsl, + Credentials = string.IsNullOrWhiteSpace(request.Username) + ? null + : new NetworkCredential(request.Username, request.Password) + }; + + using var mail = new MailMessage + { + From = new MailAddress(request.FromEmail, request.FromName), + Subject = "✅ Test email from Notification Service", + Body = "

This is a test email sent from the Notification Service admin panel to verify the channel settings.

", + IsBodyHtml = true + }; + mail.To.Add(new MailAddress(request.ToEmail)); + + await client.SendMailAsync(mail, cancellationToken); + } + catch (Exception ex) + { + Logger.Error(ex, "Ad-hoc SMTP test failed for host {Host}", request.Host); + return Failure(ex.Message); + } + + return Success(Unit.Value); + } +} diff --git a/HrynCo.NotificationService.Services/EmailChannels/Update/UpdateEmailChannelHandler.cs b/HrynCo.NotificationService.Services/EmailChannels/Update/UpdateEmailChannelHandler.cs index 5c8f1ed..0f4da77 100644 --- a/HrynCo.NotificationService.Services/EmailChannels/Update/UpdateEmailChannelHandler.cs +++ b/HrynCo.NotificationService.Services/EmailChannels/Update/UpdateEmailChannelHandler.cs @@ -1,4 +1,4 @@ -using HrynCo.NotificationService.DAL.Abstract; +using HrynCo.DAL.Abstract; using HrynCo.NotificationService.DAL.Abstract.Repositories; using HrynCo.NotificationService.Services.Core; using HrynCo.NotificationService.Services.Logging; @@ -13,9 +13,8 @@ internal sealed class UpdateEmailChannelHandler public UpdateEmailChannelHandler( IContextualSerilogLogger logger, - IUnitOfWork unitOfWork, IEmailChannelRepository channels) - : base(logger, unitOfWork) + : base(logger) { _channels = channels; } diff --git a/HrynCo.NotificationService.Services/EmailTemplates/Create/CreateEmailTemplateHandler.cs b/HrynCo.NotificationService.Services/EmailTemplates/Create/CreateEmailTemplateHandler.cs index 33c44e0..7800374 100644 --- a/HrynCo.NotificationService.Services/EmailTemplates/Create/CreateEmailTemplateHandler.cs +++ b/HrynCo.NotificationService.Services/EmailTemplates/Create/CreateEmailTemplateHandler.cs @@ -1,4 +1,3 @@ -using HrynCo.NotificationService.DAL.Abstract; using HrynCo.NotificationService.DAL.Abstract.Repositories; using HrynCo.NotificationService.DAL.Abstract.Templates; using HrynCo.NotificationService.Services.Core; @@ -14,9 +13,8 @@ internal sealed class CreateEmailTemplateHandler public CreateEmailTemplateHandler( IContextualSerilogLogger logger, - IUnitOfWork unitOfWork, IEmailTemplateRepository templates) - : base(logger, unitOfWork) + : base(logger) { _templates = templates; } diff --git a/HrynCo.NotificationService.Services/EmailTemplates/Delete/DeleteEmailTemplateHandler.cs b/HrynCo.NotificationService.Services/EmailTemplates/Delete/DeleteEmailTemplateHandler.cs index 4e924c0..b22b3c6 100644 --- a/HrynCo.NotificationService.Services/EmailTemplates/Delete/DeleteEmailTemplateHandler.cs +++ b/HrynCo.NotificationService.Services/EmailTemplates/Delete/DeleteEmailTemplateHandler.cs @@ -1,4 +1,4 @@ -using HrynCo.NotificationService.DAL.Abstract; +using HrynCo.DAL.Abstract; using HrynCo.NotificationService.DAL.Abstract.Repositories; using HrynCo.NotificationService.Services.Core; using HrynCo.NotificationService.Services.Logging; @@ -13,9 +13,8 @@ internal sealed class DeleteEmailTemplateHandler public DeleteEmailTemplateHandler( IContextualSerilogLogger logger, - IUnitOfWork unitOfWork, IEmailTemplateRepository templates) - : base(logger, unitOfWork) + : base(logger) { _templates = templates; } diff --git a/HrynCo.NotificationService.Services/EmailTemplates/Get/GetEmailTemplateHandler.cs b/HrynCo.NotificationService.Services/EmailTemplates/Get/GetEmailTemplateHandler.cs index 24b1f75..70819aa 100644 --- a/HrynCo.NotificationService.Services/EmailTemplates/Get/GetEmailTemplateHandler.cs +++ b/HrynCo.NotificationService.Services/EmailTemplates/Get/GetEmailTemplateHandler.cs @@ -1,4 +1,3 @@ -using HrynCo.NotificationService.DAL.Abstract; using HrynCo.NotificationService.DAL.Abstract.Repositories; using HrynCo.NotificationService.DAL.Abstract.Templates; using HrynCo.NotificationService.Services.Core; @@ -14,9 +13,8 @@ internal sealed class GetEmailTemplateHandler public GetEmailTemplateHandler( IContextualSerilogLogger logger, - IUnitOfWork unitOfWork, IEmailTemplateRepository templates) - : base(logger, unitOfWork) + : base(logger) { _templates = templates; } diff --git a/HrynCo.NotificationService.Services/EmailTemplates/GetAll/GetAllEmailTemplatesHandler.cs b/HrynCo.NotificationService.Services/EmailTemplates/GetAll/GetAllEmailTemplatesHandler.cs index 3ee21d2..71954a2 100644 --- a/HrynCo.NotificationService.Services/EmailTemplates/GetAll/GetAllEmailTemplatesHandler.cs +++ b/HrynCo.NotificationService.Services/EmailTemplates/GetAll/GetAllEmailTemplatesHandler.cs @@ -1,4 +1,3 @@ -using HrynCo.NotificationService.DAL.Abstract; using HrynCo.NotificationService.DAL.Abstract.Repositories; using HrynCo.NotificationService.DAL.Abstract.Templates; using HrynCo.NotificationService.Services.Core; @@ -14,9 +13,8 @@ internal sealed class GetAllEmailTemplatesHandler public GetAllEmailTemplatesHandler( IContextualSerilogLogger logger, - IUnitOfWork unitOfWork, IEmailTemplateRepository templates) - : base(logger, unitOfWork) + : base(logger) { _templates = templates; } @@ -24,7 +22,7 @@ internal sealed class GetAllEmailTemplatesHandler protected override async Task>> DoOnHandle( GetAllEmailTemplatesQuery request, CancellationToken cancellationToken) { - var templates = await _templates.GetAllAsync(cancellationToken); + var templates = await _templates.GetAllAsync(request.ServiceName, request.Key, cancellationToken); return Success(templates); } } diff --git a/HrynCo.NotificationService.Services/EmailTemplates/GetAll/GetAllEmailTemplatesQuery.cs b/HrynCo.NotificationService.Services/EmailTemplates/GetAll/GetAllEmailTemplatesQuery.cs index 7d9c13d..6718b43 100644 --- a/HrynCo.NotificationService.Services/EmailTemplates/GetAll/GetAllEmailTemplatesQuery.cs +++ b/HrynCo.NotificationService.Services/EmailTemplates/GetAll/GetAllEmailTemplatesQuery.cs @@ -4,4 +4,5 @@ using HrynCo.NotificationService.Services.Core; namespace HrynCo.NotificationService.Services.EmailTemplates.GetAll; -public sealed record GetAllEmailTemplatesQuery : IRequest>>; +public sealed record GetAllEmailTemplatesQuery(string? ServiceName = null, string? Key = null) + : IRequest>>; diff --git a/HrynCo.NotificationService.Services/EmailTemplates/GetByService/GetEmailTemplatesHandler.cs b/HrynCo.NotificationService.Services/EmailTemplates/GetByService/GetEmailTemplatesHandler.cs index 7fb5fc0..63cf086 100644 --- a/HrynCo.NotificationService.Services/EmailTemplates/GetByService/GetEmailTemplatesHandler.cs +++ b/HrynCo.NotificationService.Services/EmailTemplates/GetByService/GetEmailTemplatesHandler.cs @@ -1,4 +1,3 @@ -using HrynCo.NotificationService.DAL.Abstract; using HrynCo.NotificationService.DAL.Abstract.Repositories; using HrynCo.NotificationService.DAL.Abstract.Templates; using HrynCo.NotificationService.Services.Core; @@ -14,9 +13,8 @@ internal sealed class GetEmailTemplatesHandler public GetEmailTemplatesHandler( IContextualSerilogLogger logger, - IUnitOfWork unitOfWork, IEmailTemplateRepository templates) - : base(logger, unitOfWork) + : base(logger) { _templates = templates; } diff --git a/HrynCo.NotificationService.Services/EmailTemplates/Update/UpdateEmailTemplateHandler.cs b/HrynCo.NotificationService.Services/EmailTemplates/Update/UpdateEmailTemplateHandler.cs index 597e200..1cd842d 100644 --- a/HrynCo.NotificationService.Services/EmailTemplates/Update/UpdateEmailTemplateHandler.cs +++ b/HrynCo.NotificationService.Services/EmailTemplates/Update/UpdateEmailTemplateHandler.cs @@ -1,4 +1,4 @@ -using HrynCo.NotificationService.DAL.Abstract; +using HrynCo.DAL.Abstract; using HrynCo.NotificationService.DAL.Abstract.Repositories; using HrynCo.NotificationService.Services.Core; using HrynCo.NotificationService.Services.Logging; @@ -13,9 +13,8 @@ internal sealed class UpdateEmailTemplateHandler public UpdateEmailTemplateHandler( IContextualSerilogLogger logger, - IUnitOfWork unitOfWork, IEmailTemplateRepository templates) - : base(logger, unitOfWork) + : base(logger) { _templates = templates; } diff --git a/HrynCo.NotificationService.Web.IntegrationTests/AdminTemplatesIndexViewTests.cs b/HrynCo.NotificationService.Web.IntegrationTests/AdminTemplatesIndexViewTests.cs new file mode 100644 index 0000000..65f2ce2 --- /dev/null +++ b/HrynCo.NotificationService.Web.IntegrationTests/AdminTemplatesIndexViewTests.cs @@ -0,0 +1,32 @@ +namespace HrynCo.NotificationService.Web.IntegrationTests; + +public sealed class AdminTemplatesIndexViewTests +{ + [Fact] + public void CreateAndEditLinks_UseExplicitFilterQueryInterpolation() + { + string view = File.ReadAllText(FindIndexView()); + + Assert.Contains("/admin/templates/create@(filterQuery)", view); + Assert.Contains("@t.LanguageCode@(filterQuery)", view); + Assert.DoesNotContain("create@filterQuery", view); + Assert.DoesNotContain("LanguageCode@filterQuery", view); + } + + private static string FindIndexView() + { + DirectoryInfo? directory = new(AppContext.BaseDirectory); + while (directory is not null && !File.Exists(Path.Combine(directory.FullName, "HrynCo.NotificationService.slnx"))) + { + directory = directory.Parent; + } + + Assert.NotNull(directory); + return Path.Combine( + directory.FullName, + "HrynCo.NotificationService.Web", + "Views", + "AdminTemplates", + "Index.cshtml"); + } +} diff --git a/HrynCo.NotificationService.Web.IntegrationTests/UnitTest1.cs b/HrynCo.NotificationService.Web.IntegrationTests/UnitTest1.cs deleted file mode 100644 index 3e48a76..0000000 --- a/HrynCo.NotificationService.Web.IntegrationTests/UnitTest1.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace HrynCo.NotificationService.Web.IntegrationTests; - -public class UnitTest1 -{ - [Fact] - public void Test1() - { - - } -} diff --git a/HrynCo.NotificationService.Web/Controllers/Admin/AdminChannelsController.cs b/HrynCo.NotificationService.Web/Controllers/Admin/AdminChannelsController.cs index be74a99..6810f58 100644 --- a/HrynCo.NotificationService.Web/Controllers/Admin/AdminChannelsController.cs +++ b/HrynCo.NotificationService.Web/Controllers/Admin/AdminChannelsController.cs @@ -5,6 +5,7 @@ using HrynCo.NotificationService.Services.EmailChannels.Delete; using HrynCo.NotificationService.Services.EmailChannels.Get; using HrynCo.NotificationService.Services.EmailChannels.GetUsageSummary; using HrynCo.NotificationService.Services.EmailChannels.Send; +using HrynCo.NotificationService.Services.EmailChannels.TestSmtp; using HrynCo.NotificationService.Services.EmailChannels.Update; using HrynCo.NotificationService.Web.Controllers.Admin.ViewModels; using MediatR; @@ -133,6 +134,20 @@ public class AdminChannelsController(IMediator mediator) : Controller return RedirectToAction(nameof(Index)); } + // POST /admin/channels/test-smtp + [HttpPost("test-smtp")] + public async Task TestSmtp([FromBody] TestSmtpRequest request, CancellationToken ct) + { + var result = await mediator.Send(new TestSmtpCommand( + request.Host, request.Port, request.Username, request.Password, + request.UseSsl, request.FromEmail, request.FromName, request.ToEmail), ct); + + if (!result.IsSuccess) + return Ok(new { success = false, message = result.Error?.Message }); + + return Ok(new { success = true, message = $"Test email sent to {request.ToEmail}." }); + } + // POST /admin/channels/{id}/test [HttpPost("{id:guid}/test")] public async Task Test(Guid id, [FromBody] TestChannelRequest request, CancellationToken ct) @@ -165,3 +180,6 @@ public class AdminChannelsController(IMediator mediator) : Controller } public record TestChannelRequest(string ToEmail); +public record TestSmtpRequest( + string Host, int Port, string Username, string Password, + bool UseSsl, string FromEmail, string FromName, string ToEmail); diff --git a/HrynCo.NotificationService.Web/Controllers/Admin/AdminTemplatesController.cs b/HrynCo.NotificationService.Web/Controllers/Admin/AdminTemplatesController.cs index f25b98a..c8c35b6 100644 --- a/HrynCo.NotificationService.Web/Controllers/Admin/AdminTemplatesController.cs +++ b/HrynCo.NotificationService.Web/Controllers/Admin/AdminTemplatesController.cs @@ -23,9 +23,12 @@ public class AdminTemplatesController : Controller // GET /admin/templates [HttpGet("")] - public async Task Index(CancellationToken ct) + public async Task Index([FromQuery] string? serviceName, [FromQuery] string? key, CancellationToken ct) { - var result = await _mediator.Send(new GetAllEmailTemplatesQuery(), ct); + ViewData["ServiceNameFilter"] = serviceName; + ViewData["KeyFilter"] = key; + + var result = await _mediator.Send(new GetAllEmailTemplatesQuery(serviceName, key), ct); if (!result.IsSuccess) { ModelState.AddModelError("", result.Error?.Message ?? "Failed to load templates."); @@ -37,14 +40,24 @@ public class AdminTemplatesController : Controller // GET /admin/templates/create [HttpGet("create")] - public IActionResult Create() + public IActionResult Create([FromQuery] string? serviceNameFilter, [FromQuery] string? keyFilter) { - return View("Edit", new EmailTemplateEditViewModel()); + return View("Edit", new EmailTemplateEditViewModel + { + ServiceNameFilter = serviceNameFilter, + KeyFilter = keyFilter + }); } // GET /admin/templates/{serviceName}/{key}/{languageCode} [HttpGet("{serviceName}/{key}/{languageCode}")] - public async Task Edit(string serviceName, string key, string languageCode, CancellationToken ct) + public async Task Edit( + string serviceName, + string key, + string languageCode, + [FromQuery] string? serviceNameFilter, + [FromQuery] string? keyFilter, + CancellationToken ct) { var result = await _mediator.Send(new GetEmailTemplateQuery(serviceName, key, languageCode), ct); if (!result.IsSuccess || result.Result is null) @@ -60,7 +73,9 @@ public class AdminTemplatesController : Controller Subject = template.Subject, HtmlBody = template.HtmlBody, TextBody = template.TextBody, - VariablesJson = JsonSerializer.Serialize(template.Variables) + VariablesJson = JsonSerializer.Serialize(template.Variables), + ServiceNameFilter = serviceNameFilter, + KeyFilter = keyFilter }; return View(vm); @@ -124,15 +139,21 @@ public class AdminTemplatesController : Controller } } - return RedirectToAction(nameof(Index)); + return RedirectToAction(nameof(Index), new { serviceName = model.ServiceNameFilter, key = model.KeyFilter }); } // POST /admin/templates/{serviceName}/{key}/{languageCode}/delete [HttpPost("{serviceName}/{key}/{languageCode}/delete")] [ValidateAntiForgeryToken] - public async Task Delete(string serviceName, string key, string languageCode, CancellationToken ct) + public async Task Delete( + string serviceName, + string key, + string languageCode, + [FromForm] string? serviceNameFilter, + [FromForm] string? keyFilter, + CancellationToken ct) { await _mediator.Send(new DeleteEmailTemplateCommand(serviceName, key, languageCode), ct); - return RedirectToAction(nameof(Index)); + return RedirectToAction(nameof(Index), new { serviceName = serviceNameFilter, key = keyFilter }); } } diff --git a/HrynCo.NotificationService.Web/Controllers/Admin/ViewModels/EmailTemplateEditViewModel.cs b/HrynCo.NotificationService.Web/Controllers/Admin/ViewModels/EmailTemplateEditViewModel.cs index d6600a3..ae61226 100644 --- a/HrynCo.NotificationService.Web/Controllers/Admin/ViewModels/EmailTemplateEditViewModel.cs +++ b/HrynCo.NotificationService.Web/Controllers/Admin/ViewModels/EmailTemplateEditViewModel.cs @@ -25,6 +25,8 @@ public class EmailTemplateEditViewModel // JSON array: [{"name":"UserName","required":true}, ...] public string VariablesJson { get; set; } = "[]"; + public string? ServiceNameFilter { get; set; } + public string? KeyFilter { get; set; } public bool IsNew => Id == null; public string PageTitle => IsNew ? "Create Email Template" : "Edit Email Template"; diff --git a/HrynCo.NotificationService.Web/Controllers/Api/EmailTemplates/EmailTemplatesController.cs b/HrynCo.NotificationService.Web/Controllers/Api/EmailTemplates/EmailTemplatesController.cs index 30bd332..e4b43f8 100644 --- a/HrynCo.NotificationService.Web/Controllers/Api/EmailTemplates/EmailTemplatesController.cs +++ b/HrynCo.NotificationService.Web/Controllers/Api/EmailTemplates/EmailTemplatesController.cs @@ -1,6 +1,7 @@ using HrynCo.NotificationService.Web.Infrastructure; using HrynCo.NotificationService.Services.EmailTemplates.Create; using HrynCo.NotificationService.Services.EmailTemplates.Delete; +using HrynCo.NotificationService.Services.EmailTemplates.GetAll; using HrynCo.NotificationService.Services.EmailTemplates.Get; using HrynCo.NotificationService.Services.EmailTemplates.GetByService; using HrynCo.NotificationService.Services.EmailTemplates.Update; @@ -15,9 +16,9 @@ public sealed class EmailTemplatesController : ApiControllerBase public EmailTemplatesController(IMediator mediator) : base(mediator) { } [HttpGet] - public async Task GetAll([FromQuery] string serviceName, CancellationToken cancellationToken) + public async Task GetAll([FromQuery] string? serviceName, [FromQuery] string? key, CancellationToken cancellationToken) { - var result = await Mediator.Send(new GetEmailTemplatesQuery(serviceName), cancellationToken); + var result = await Mediator.Send(new GetAllEmailTemplatesQuery(serviceName, key), cancellationToken); return FromServiceResult(result); } diff --git a/HrynCo.NotificationService.Web/HrynCo.NotificationService.Web.http b/HrynCo.NotificationService.Web/HrynCo.NotificationService.Web.http index 51c1dc1..2e3119a 100644 --- a/HrynCo.NotificationService.Web/HrynCo.NotificationService.Web.http +++ b/HrynCo.NotificationService.Web/HrynCo.NotificationService.Web.http @@ -1,6 +1,47 @@ -@HrynCo.NotificationService.Api_HostAddress = http://localhost:5188 +@host = http://localhost:5188 -GET {{HrynCo.NotificationService.Api_HostAddress}}/weatherforecast/ -Accept: application/json +### Create a new email template +POST {{host}}/api/v1/email-templates +Content-Type: application/json -### +{ + "ServiceName": "StoreMate-Prod", + "Key": "ShareInvite", + "LanguageCode": "uk", + "Subject": "Вас запрошено", + "HtmlBody": "

Вітаємо, \u007b\u007bRecipientName\u007d\u007d.

Вас запрошено

\u007b\u007bInviterName\u007d\u007d запросив вас приєднатися до \u007b\u007bAppName\u007d\u007d, щоб ви могли безпечно співпрацювати.

Відкрити запрошення

Запрошення дійсне до \u007b\u007bValidUntil\u007d\u007d.

", + "TextBody": "Вітаємо, \u007b\u007bRecipientName\u007d\u007d.\n\n\u007b\u007bInviterName\u007d\u007d запросив вас приєднатися до \u007b\u007bAppName\u007d\u007d, щоб ви могли безпечно співпрацювати.\n\nВідкрийте запрошення: \u007b\u007bInviteLink\u007d\u007d\nДійсне до: \u007b\u007bValidUntil\u007d\u007d", + "Variables": [ + { "Name": "RecipientName", "Required": false }, + { "Name": "InviterName", "Required": false }, + { "Name": "AppName", "Required": false }, + { "Name": "InviteLink", "Required": false }, + { "Name": "ValidUntil", "Required": false } + ] +} + +### Get the created template +GET {{host}}/api/v1/email-templates/StoreMate-Prod/ShareInvite/uk + +### List all templates for the service +GET {{host}}/api/v1/email-templates?serviceName=StoreMate-Prod + +### Update the template +PUT {{host}}/api/v1/email-templates/StoreMate-Prod/ShareInvite/uk +Content-Type: application/json + +{ + "Subject": "Вас запрошено", + "HtmlBody": "

Вітаємо, \u007b\u007bRecipientName\u007d\u007d.

\u007b\u007bInviterName\u007d\u007d запросив вас приєднатися до \u007b\u007bAppName\u007d\u007d.

Відкрити запрошення

Дійсне до \u007b\u007bValidUntil\u007d\u007d.

", + "TextBody": "Вітаємо, \u007b\u007bRecipientName\u007d\u007d.\n\n\u007b\u007bInviterName\u007d\u007d запросив вас приєднатися до \u007b\u007bAppName\u007d\u007d.\n\nВідкрийте запрошення: \u007b\u007bInviteLink\u007d\u007d\nДійсне до: \u007b\u007bValidUntil\u007d\u007d", + "Variables": [ + { "Name": "RecipientName", "Required": false }, + { "Name": "InviterName", "Required": false }, + { "Name": "AppName", "Required": false }, + { "Name": "InviteLink", "Required": false }, + { "Name": "ValidUntil", "Required": false } + ] +} + +### Delete the template +DELETE {{host}}/api/v1/email-templates/StoreMate-Prod/ShareInvite/uk diff --git a/HrynCo.NotificationService.Web/Program.cs b/HrynCo.NotificationService.Web/Program.cs index 01bc011..cf50e59 100644 --- a/HrynCo.NotificationService.Web/Program.cs +++ b/HrynCo.NotificationService.Web/Program.cs @@ -3,11 +3,11 @@ using HrynCo.NotificationService.DAL.EF; using HrynCo.NotificationService.Services; using Scalar.AspNetCore; -var builder = WebApplication.CreateBuilder(args); +WebApplicationBuilder builder = WebApplication.CreateBuilder(args); builder.AddSerilog(); -var appSettings = builder.Configuration +AppSettings appSettings = builder.Configuration .GetSection(AppSettings.SectionName) .Get() ?? throw new InvalidOperationException("App settings are not configured."); @@ -18,17 +18,14 @@ builder.Services.AddControllersWithViews() builder.Services.AddNotificationDataAccess(appSettings.ConnectionString); builder.Services.AddNotificationServices(); -var app = builder.Build(); +WebApplication app = builder.Build(); -if (app.Environment.IsDevelopment()) +app.MapOpenApi(); +app.MapScalarApiReference(options => { - app.MapOpenApi(); - app.MapScalarApiReference(options => - { - options.Title = "HrynCo Notification Service"; - options.Theme = ScalarTheme.DeepSpace; - }); -} + options.Title = "HrynCo Notification Service"; + options.Theme = ScalarTheme.DeepSpace; +}); app.UseStaticFiles(); app.UseHttpsRedirection(); diff --git a/HrynCo.NotificationService.Web/Views/AdminChannels/Edit.cshtml b/HrynCo.NotificationService.Web/Views/AdminChannels/Edit.cshtml index 44685df..c91baac 100644 --- a/HrynCo.NotificationService.Web/Views/AdminChannels/Edit.cshtml +++ b/HrynCo.NotificationService.Web/Views/AdminChannels/Edit.cshtml @@ -121,82 +121,90 @@ - @if (!Model.IsNew) - { - - } + Cancel } -@if (!Model.IsNew) -{ -