From 5003ab8764cb67277fa657b76b19bdb957758411 Mon Sep 17 00:00:00 2001 From: Anatolii Grynchuk Date: Sat, 2 May 2026 14:23:17 +0300 Subject: [PATCH 01/26] fix: move ManagePackageVersionsCentrally to Directory.Packages.props Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Directory.Build.props | 6 +----- Directory.Packages.props | 3 +++ 2 files changed, 4 insertions(+), 5 deletions(-) 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..6d4fb99 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -1,4 +1,7 @@ + + true + -- 2.52.0 From 74211f0a4a5f0b978a648e0ed4a23867c5a74989 Mon Sep 17 00:00:00 2001 From: Anatolii Grynchuk Date: Sat, 2 May 2026 14:40:01 +0300 Subject: [PATCH 02/26] chore: add NuGet metadata to Contracts project - Add PackageId, Authors, Description, PackageTags, RepositoryUrl - Matches metadata pattern from HrynCo.Common and HrynCo.RabbitMq Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../HrynCo.NotificationService.Contracts.csproj | 6 ++++++ 1 file changed, 6 insertions(+) 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 -- 2.52.0 From ae119d1a3dd573628f70ff2a096f08fa57eb6b99 Mon Sep 17 00:00:00 2001 From: Anatolii Grynchuk Date: Sat, 2 May 2026 15:25:09 +0300 Subject: [PATCH 03/26] feat: add production docker-compose with hrynco-services network - Base compose: explicit internal network, named volumes with VOLUME_PREFIX - docker-compose.prod.yml: production images, ports, restart policies, hrynco-services external network on rabbitmq - docker-compose.Development.yml: cleaned up orphan volumes, named dev volumes Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../docker-compose.Development.yml | 32 ++++++------- docker/environments/docker-compose.prod.yml | 37 +++++++++++++++ docker/environments/docker-compose.yml | 45 +++++++++++++++---- 3 files changed, 88 insertions(+), 26 deletions(-) create mode 100644 docker/environments/docker-compose.prod.yml diff --git a/docker/environments/docker-compose.Development.yml b/docker/environments/docker-compose.Development.yml index d4bac3e..0327a4d 100644 --- a/docker/environments/docker-compose.Development.yml +++ b/docker/environments/docker-compose.Development.yml @@ -26,31 +26,22 @@ services: condition: service_healthy rabbitmq: - image: rabbitmq:4-management-alpine environment: RABBITMQ_DEFAULT_USER: guest RABBITMQ_DEFAULT_PASS: guest ports: - "5672:5672" - "15672:15672" - healthcheck: - test: ["CMD", "rabbitmq-diagnostics", "ping"] - interval: 10s - timeout: 5s - retries: 5 - volumes: - - notification_rabbitmq:/var/lib/rabbitmq + networks: + - internal db: - image: postgres:17 - environment: - POSTGRES_DB: notification_service - POSTGRES_USER: postgres - POSTGRES_PASSWORD: postgres ports: - "5433:5432" volumes: - - notification_db:/var/lib/postgresql/data + - pgdata:/var/lib/postgresql/data + networks: + - internal seq: image: datalust/seq:2024 @@ -60,9 +51,14 @@ services: ports: - "5342:80" volumes: - - notification_seq:/data + - seq_data:/data + networks: + - internal volumes: - notification_db: - notification_seq: - notification_rabbitmq: \ No newline at end of file + pgdata: + name: ns-dev-pgdata + rabbitmq_data: + name: ns-dev-rabbitmq-data + seq_data: + name: ns-dev-seq \ No newline at end of file diff --git a/docker/environments/docker-compose.prod.yml b/docker/environments/docker-compose.prod.yml new file mode 100644 index 0000000..d73a33d --- /dev/null +++ b/docker/environments/docker-compose.prod.yml @@ -0,0 +1,37 @@ +services: + migrator: + build: {} + image: registry.grynco.com.ua/hrynco.notification-service.migrator:${MIGRATOR_IMAGE_TAG:?MIGRATOR_IMAGE_TAG is required} + + api: + build: {} + image: registry.grynco.com.ua/hrynco.notification-service.web:${WEB_IMAGE_TAG:?WEB_IMAGE_TAG is required} + ports: + - "${API_PORT:?API_PORT is required}:8080" + environment: + - Serilog__WriteTo__1__Args__serverUrl=${SEQ_URL:-} + restart: always + + worker: + build: {} + image: registry.grynco.com.ua/hrynco.notification-service.worker:${WORKER_IMAGE_TAG:?WORKER_IMAGE_TAG is required} + environment: + - Serilog__WriteTo__1__Args__serverUrl=${SEQ_URL:-} + restart: always + + rabbitmq: + restart: always + networks: + - internal + - hrynco-services + + db: + ports: + - "${DB_PORT:?DB_PORT is required}:5432" + restart: always + +networks: + internal: {} + hrynco-services: + external: true + name: hrynco-services diff --git a/docker/environments/docker-compose.yml b/docker/environments/docker-compose.yml index bb39613..143b22f 100644 --- a/docker/environments/docker-compose.yml +++ b/docker/environments/docker-compose.yml @@ -6,7 +6,7 @@ services: context: ../.. dockerfile: HrynCo.NotificationService.Migrator/Dockerfile environment: - - App__ConnectionString=${CONNECTION_STRING} + - App__ConnectionString=Host=db;Port=5432;Database=${DB_NAME:?DB_NAME is required};Username=${DB_USER:?DB_USER is required};Password=${DB_PASS:?DB_PASS is required} depends_on: db: condition: service_started @@ -18,7 +18,7 @@ services: dockerfile: HrynCo.NotificationService.Web/Dockerfile environment: - ASPNETCORE_ENVIRONMENT=Production - - App__ConnectionString=${CONNECTION_STRING} + - App__ConnectionString=Host=db;Port=5432;Database=${DB_NAME:?DB_NAME is required};Username=${DB_USER:?DB_USER is required};Password=${DB_PASS:?DB_PASS is required} depends_on: db: condition: service_started @@ -31,10 +31,11 @@ services: dockerfile: HrynCo.NotificationService.Worker/Dockerfile environment: - DOTNET_ENVIRONMENT=Production - - App__ConnectionString=${CONNECTION_STRING} + - App__ConnectionString=Host=db;Port=5432;Database=${DB_NAME:?DB_NAME is required};Username=${DB_USER:?DB_USER is required};Password=${DB_PASS:?DB_PASS is required} - App__RabbitMq__Host=rabbitmq - - App__RabbitMq__User=${RABBITMQ_USER:-guest} - - App__RabbitMq__Password=${RABBITMQ_PASSWORD:-guest} + - App__RabbitMq__Port=5672 + - App__RabbitMq__User=${RABBITMQ_USER:?RABBITMQ_USER is required} + - App__RabbitMq__Password=${RABBITMQ_PASSWORD:?RABBITMQ_PASSWORD is required} depends_on: db: condition: service_started @@ -46,10 +47,38 @@ services: rabbitmq: image: rabbitmq:4-management-alpine environment: - RABBITMQ_DEFAULT_USER: ${RABBITMQ_USER:-guest} - RABBITMQ_DEFAULT_PASS: ${RABBITMQ_PASSWORD:-guest} + RABBITMQ_DEFAULT_USER: ${RABBITMQ_USER:?RABBITMQ_USER is required} + RABBITMQ_DEFAULT_PASS: ${RABBITMQ_PASSWORD:?RABBITMQ_PASSWORD is required} + ports: + - "${RABBITMQ_AMQP_PORT:?RABBITMQ_AMQP_PORT is required}:5672" + - "${RABBITMQ_MANAGEMENT_PORT:?RABBITMQ_MANAGEMENT_PORT is required}:15672" + volumes: + - rabbitmq_data:/var/lib/rabbitmq + networks: + - internal healthcheck: test: ["CMD", "rabbitmq-diagnostics", "ping"] interval: 10s timeout: 5s - retries: 5 \ No newline at end of file + retries: 5 + + db: + image: postgres:17 + environment: + - POSTGRES_DB=${DB_NAME:?DB_NAME is required} + - POSTGRES_USER=${DB_USER:?DB_USER is required} + - POSTGRES_PASSWORD=${DB_PASS:?DB_PASS is required} + volumes: + - pgdata:/var/lib/postgresql/data + networks: + - internal + +volumes: + pgdata: + name: ${VOLUME_PREFIX:?VOLUME_PREFIX is required}-pgdata + rabbitmq_data: + name: ${VOLUME_PREFIX:?VOLUME_PREFIX is required}-rabbitmq-data + +networks: + internal: + driver: bridge \ No newline at end of file -- 2.52.0 From c88511ce3b73d1f4f244d876de1310dffda98ef9 Mon Sep 17 00:00:00 2001 From: Anatolii Grynchuk Date: Sat, 2 May 2026 15:40:12 +0300 Subject: [PATCH 04/26] chore: update package versions and formatting in Directory.Packages.props Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Directory.Packages.props | 85 ++++++++++++++++++---------------------- 1 file changed, 39 insertions(+), 46 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 6d4fb99..fce03e0 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -1,47 +1,40 @@ - - true - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file -- 2.52.0 From 166b1a6103e57a99c1abeade9cb09873c3cd1339 Mon Sep 17 00:00:00 2001 From: Anatolii Grynchuk Date: Sat, 2 May 2026 16:38:09 +0300 Subject: [PATCH 05/26] fix: wait for postgres healthcheck before running migrator - Add pg_isready healthcheck to db service (5s interval, 10 retries) - Change migrator depends_on condition: service_started -> service_healthy - Prevents migrator connection failure on fresh postgres startup Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docker/environments/docker-compose.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/docker/environments/docker-compose.yml b/docker/environments/docker-compose.yml index 143b22f..b598113 100644 --- a/docker/environments/docker-compose.yml +++ b/docker/environments/docker-compose.yml @@ -9,7 +9,7 @@ services: - App__ConnectionString=Host=db;Port=5432;Database=${DB_NAME:?DB_NAME is required};Username=${DB_USER:?DB_USER is required};Password=${DB_PASS:?DB_PASS is required} depends_on: db: - condition: service_started + condition: service_healthy restart: "no" api: @@ -72,6 +72,11 @@ services: - pgdata:/var/lib/postgresql/data networks: - internal + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${DB_USER}"] + interval: 5s + timeout: 5s + retries: 10 volumes: pgdata: -- 2.52.0 From c5528b253da8ede51824f7f2b8f98fbc81fd9c9d Mon Sep 17 00:00:00 2001 From: Anatolii Grynchuk Date: Sat, 2 May 2026 18:31:34 +0300 Subject: [PATCH 06/26] fix: add internal network to migrator, api, worker services - migrator, api, worker were missing 'networks: - internal' - db and rabbitmq are only on internal network, so services couldn't reach them - also changed api depends_on db condition to service_healthy Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docker/environments/docker-compose.yml | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/docker/environments/docker-compose.yml b/docker/environments/docker-compose.yml index b598113..a1b5174 100644 --- a/docker/environments/docker-compose.yml +++ b/docker/environments/docker-compose.yml @@ -10,6 +10,8 @@ services: depends_on: db: condition: service_healthy + networks: + - internal restart: "no" api: @@ -21,9 +23,11 @@ services: - App__ConnectionString=Host=db;Port=5432;Database=${DB_NAME:?DB_NAME is required};Username=${DB_USER:?DB_USER is required};Password=${DB_PASS:?DB_PASS is required} depends_on: db: - condition: service_started + condition: service_healthy migrator: condition: service_completed_successfully + networks: + - internal worker: build: @@ -38,11 +42,13 @@ services: - App__RabbitMq__Password=${RABBITMQ_PASSWORD:?RABBITMQ_PASSWORD is required} depends_on: db: - condition: service_started + condition: service_healthy migrator: condition: service_completed_successfully rabbitmq: condition: service_healthy + networks: + - internal rabbitmq: image: rabbitmq:4-management-alpine -- 2.52.0 From d71c3513a505c1c5ea26e51478f04824a25ebe42 Mon Sep 17 00:00:00 2001 From: Anatolii Grynchuk Date: Sat, 2 May 2026 18:43:20 +0300 Subject: [PATCH 07/26] fix: add missing FK migration for EmailChannelUsage -> EmailChannel - EF model had a pending HasOne/WithMany relationship not in migrations - Adds FK_email_channel_usage_email_channels_provider_id with cascade delete Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../20260502154249_PendingChanges.Designer.cs | 225 ++++++++++++++++++ .../20260502154249_PendingChanges.cs | 30 +++ .../NotificationDbContextModelSnapshot.cs | 14 ++ 3 files changed, 269 insertions(+) create mode 100644 HrynCo.NotificationService.DAL.EF/Migrations/20260502154249_PendingChanges.Designer.cs create mode 100644 HrynCo.NotificationService.DAL.EF/Migrations/20260502154249_PendingChanges.cs 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 } } -- 2.52.0 From 3e1cc696c1e5562167093f5a4bfc164939a6bccc Mon Sep 17 00:00:00 2001 From: Anatolii Grynchuk Date: Sat, 2 May 2026 18:50:16 +0300 Subject: [PATCH 08/26] fix: rename api service to web in all docker-compose files - Aligns compose service name with the image name (hrynco.notification-service.web) - Rename API_PORT env var to WEB_PORT for consistency Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docker/environments/docker-compose.Development.yml | 2 +- docker/environments/docker-compose.prod.yml | 4 ++-- docker/environments/docker-compose.yml | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docker/environments/docker-compose.Development.yml b/docker/environments/docker-compose.Development.yml index 0327a4d..f7fc8da 100644 --- a/docker/environments/docker-compose.Development.yml +++ b/docker/environments/docker-compose.Development.yml @@ -5,7 +5,7 @@ services: environment: - App__ConnectionString=Host=db;Port=5432;Database=notification_service;Username=postgres;Password=postgres - api: + web: environment: - ASPNETCORE_ENVIRONMENT=Development - App__ConnectionString=Host=db;Port=5432;Database=notification_service;Username=postgres;Password=postgres diff --git a/docker/environments/docker-compose.prod.yml b/docker/environments/docker-compose.prod.yml index d73a33d..568a04c 100644 --- a/docker/environments/docker-compose.prod.yml +++ b/docker/environments/docker-compose.prod.yml @@ -3,11 +3,11 @@ services: build: {} image: registry.grynco.com.ua/hrynco.notification-service.migrator:${MIGRATOR_IMAGE_TAG:?MIGRATOR_IMAGE_TAG is required} - api: + web: build: {} image: registry.grynco.com.ua/hrynco.notification-service.web:${WEB_IMAGE_TAG:?WEB_IMAGE_TAG is required} ports: - - "${API_PORT:?API_PORT is required}:8080" + - "${WEB_PORT:?WEB_PORT is required}:8080" environment: - Serilog__WriteTo__1__Args__serverUrl=${SEQ_URL:-} restart: always diff --git a/docker/environments/docker-compose.yml b/docker/environments/docker-compose.yml index a1b5174..ee4e189 100644 --- a/docker/environments/docker-compose.yml +++ b/docker/environments/docker-compose.yml @@ -14,7 +14,7 @@ services: - internal restart: "no" - api: + web: build: context: ../.. dockerfile: HrynCo.NotificationService.Web/Dockerfile -- 2.52.0 From 6302a071788084283170916db7532a3493071f27 Mon Sep 17 00:00:00 2001 From: Anatolii Grynchuk Date: Sat, 2 May 2026 19:53:20 +0300 Subject: [PATCH 09/26] feat: add test button to create channel form using ad-hoc smtp test endpoint - Add TestSmtpCommand and TestSmtpHandler for ad-hoc smtp testing without saving - Add POST /admin/channels/test-smtp endpoint accepting raw smtp settings - Show Test button on both Create and Edit forms - Test reads current form values so channel can be tested before saving --- .../EmailChannels/TestSmtp/TestSmtpCommand.cs | 18 +++ .../EmailChannels/TestSmtp/TestSmtpHandler.cs | 52 +++++++ .../Admin/AdminChannelsController.cs | 18 +++ .../Views/AdminChannels/Edit.cshtml | 144 +++++++++--------- 4 files changed, 164 insertions(+), 68 deletions(-) create mode 100644 HrynCo.NotificationService.Services/EmailChannels/TestSmtp/TestSmtpCommand.cs create mode 100644 HrynCo.NotificationService.Services/EmailChannels/TestSmtp/TestSmtpHandler.cs 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..3060906 --- /dev/null +++ b/HrynCo.NotificationService.Services/EmailChannels/TestSmtp/TestSmtpHandler.cs @@ -0,0 +1,52 @@ +using System.Net; +using System.Net.Mail; +using HrynCo.NotificationService.DAL.Abstract; +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, + IUnitOfWork unitOfWork) + : base(logger, unitOfWork) + { + } + + 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.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/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) -{ - +@RenderSection("Scripts", required: false) diff --git a/HrynCo.NotificationService.Web/wwwroot/css/admin.css b/HrynCo.NotificationService.Web/wwwroot/css/admin.css index 46cf428..40bbb6d 100644 --- a/HrynCo.NotificationService.Web/wwwroot/css/admin.css +++ b/HrynCo.NotificationService.Web/wwwroot/css/admin.css @@ -154,6 +154,109 @@ body { border-radius: 0 0 .5rem .5rem !important; } +/* ── Email template preview ───────────────────────────── */ +.template-preview-panel { + border: 1px solid #dce3eb; + border-radius: .75rem; + background: #fff; + overflow: hidden; + box-shadow: 0 10px 24px rgba(15, 23, 42, .06); +} + +.template-preview-panel-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 1rem; + padding: 1rem 1.25rem; + background: linear-gradient(180deg, #f9fbff 0%, #f3f6fb 100%); + border-bottom: 1px solid #e1e7ef; +} + +.template-preview-title { + font-size: .9rem; + font-weight: 700; + color: #1f2937; +} + +.template-preview-subtitle { + font-size: .82rem; + color: #6b7280; + margin-top: .15rem; +} + +.template-preview-grid { + display: grid; + grid-template-columns: minmax(240px, 300px) minmax(0, 1fr); + gap: 1rem; + padding: 1rem 1.25rem 1.25rem; +} + +.template-preview-source, +.template-preview-output { + min-width: 0; +} + +.template-preview-section-title { + font-size: .7rem; + font-weight: 700; + letter-spacing: .08em; + text-transform: uppercase; + color: #6b7280; + margin-bottom: .5rem; +} + +.template-preview-variables { + display: flex; + flex-direction: column; + gap: .75rem; +} + +.template-preview-variables .form-control-sm { + background: #fff; +} + +.template-preview-subject { + padding: .75rem 1rem; + border: 1px dashed #cfd8e3; + border-radius: .5rem; + background: #f8fafc; + min-height: 3rem; + font-weight: 600; + color: #111827; + white-space: pre-wrap; + word-break: break-word; +} + +.template-preview-frame { + width: 100%; + height: 420px; + border: 1px solid #cfd8e3; + border-radius: .5rem; + background: #eef2f7; +} + +.template-preview-text { + padding: .75rem 1rem; + border: 1px solid #cfd8e3; + border-radius: .5rem; + background: #0f172a; + color: #e2e8f0; + min-height: 120px; + white-space: pre-wrap; + word-break: break-word; +} + +@media (max-width: 992px) { + .template-preview-grid { + grid-template-columns: 1fr; + } + + .template-preview-frame { + height: 360px; + } +} + /* ── Form-section divider ─────────────────────────────── */ .form-section-title { font-size: .68rem; diff --git a/docker/environments/.env.Development b/docker/environments/.env.Development new file mode 100644 index 0000000..5ee5ffd --- /dev/null +++ b/docker/environments/.env.Development @@ -0,0 +1,9 @@ +DB_NAME=notification_service +DB_USER=postgres +DB_PASS=postgres +VOLUME_PREFIX=ns-dev +RABBITMQ_USER=guest +RABBITMQ_PASSWORD=guest +RABBITMQ_AMQP_PORT=5672 +RABBITMQ_MANAGEMENT_PORT=15672 +WEB_PORT=5200 -- 2.52.0 From b4d8497ea72aec76764cdcf685082c12712f9c2f Mon Sep 17 00:00:00 2001 From: Anatolii Grynchuk Date: Tue, 12 May 2026 22:21:00 +0300 Subject: [PATCH 14/26] refactor: use AsNoTracking for email template queries to improve performance --- .../Repositories/EmailTemplateRepository.cs | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/HrynCo.NotificationService.DAL.EF/Repositories/EmailTemplateRepository.cs b/HrynCo.NotificationService.DAL.EF/Repositories/EmailTemplateRepository.cs index fc6c6ec..6bf0e25 100644 --- a/HrynCo.NotificationService.DAL.EF/Repositories/EmailTemplateRepository.cs +++ b/HrynCo.NotificationService.DAL.EF/Repositories/EmailTemplateRepository.cs @@ -14,13 +14,16 @@ internal sealed class EmailTemplateRepository : EfRepository> GetAllAsync(CancellationToken ct = default) { - List entities = await DbSet.ToListAsync(ct); + List entities = await DbSet + .AsNoTracking() + .ToListAsync(ct); return entities.Select(MapToDomain).ToList(); } public async Task> GetByServiceAsync(string serviceName, CancellationToken ct = default) { List entities = await DbSet + .AsNoTracking() .Where(x => x.ServiceName == serviceName) .ToListAsync(ct); @@ -29,8 +32,10 @@ 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 DbSet + .AsNoTracking() + .FirstOrDefaultAsync( + x => x.ServiceName == serviceName && x.Key == key && x.LanguageCode == languageCode, ct); return entity is null ? null : MapToDomain(entity); } @@ -80,4 +85,4 @@ internal sealed class EmailTemplateRepository : EfRepository Date: Wed, 13 May 2026 02:08:43 +0300 Subject: [PATCH 15/26] refactor: replace internal UnitOfWork with NotificationUnitOfWork and NotificationBaseRepository - Consolidate unit of work implementation with NotificationUnitOfWork. - Refactor repositories to use NotificationBaseRepository for consistency. - Simplify request handlers by removing IUnitOfWork dependency. - Update related tests and service registration. --- Directory.Packages.props | 6 +- ...Co.NotificationService.DAL.Abstract.csproj | 8 +- .../Providers/EmailChannel.cs | 2 +- .../Providers/EmailChannelUsage.cs | 7 +- .../Core/NotificationBaseRepository.cs | 20 +++ .../Core/NotificationEfRepository.cs | 13 ++ .../Core/UnitOfWork.cs | 10 -- .../HrynCo.NotificationService.DAL.EF.csproj | 1 - .../NotificationUnitOfWork.cs | 10 ++ .../Repositories/EmailChannelRepository.cs | 41 ++--- .../EmailChannelUsageRepository.cs | 16 +- .../Repositories/EmailTemplateRepository.cs | 25 +-- .../ServiceCollectionExtensions.cs | 2 +- .../Core/RequestHandler.cs | 7 +- .../Create/CreateEmailChannelHandler.cs | 3 +- .../Delete/DeleteEmailChannelHandler.cs | 3 +- .../Get/GetEmailChannelHandler.cs | 4 +- .../GetAll/GetAllEmailChannelsHandler.cs | 4 +- .../GetByService/GetEmailChannelsHandler.cs | 4 +- .../GetChannelUsageSummaryHandler.cs | 4 +- .../EmailChannels/Send/SendEmailHandler.cs | 4 +- .../EmailChannels/TestSmtp/TestSmtpHandler.cs | 6 +- .../Update/UpdateEmailChannelHandler.cs | 3 +- .../Create/CreateEmailTemplateHandler.cs | 4 +- .../Delete/DeleteEmailTemplateHandler.cs | 3 +- .../Get/GetEmailTemplateHandler.cs | 4 +- .../GetAll/GetAllEmailTemplatesHandler.cs | 4 +- .../GetByService/GetEmailTemplatesHandler.cs | 4 +- .../Update/UpdateEmailTemplateHandler.cs | 3 +- .../HrynCo.NotificationService.Web.http | 49 +++++- .../Views/AdminTemplates/Edit.cshtml | 149 ++++++++++-------- .../wwwroot/css/admin.css | 39 ++++- 32 files changed, 276 insertions(+), 186 deletions(-) create mode 100644 HrynCo.NotificationService.DAL.EF/Core/NotificationBaseRepository.cs create mode 100644 HrynCo.NotificationService.DAL.EF/Core/NotificationEfRepository.cs delete mode 100644 HrynCo.NotificationService.DAL.EF/Core/UnitOfWork.cs create mode 100644 HrynCo.NotificationService.DAL.EF/NotificationUnitOfWork.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index ebb6ee0..9d827a7 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -4,6 +4,8 @@ + + @@ -29,8 +31,6 @@ - - @@ -39,4 +39,4 @@ -
+ \ No newline at end of file diff --git a/HrynCo.NotificationService.DAL.Abstract/HrynCo.NotificationService.DAL.Abstract.csproj b/HrynCo.NotificationService.DAL.Abstract/HrynCo.NotificationService.DAL.Abstract.csproj index 0e19145..bcb034d 100644 --- a/HrynCo.NotificationService.DAL.Abstract/HrynCo.NotificationService.DAL.Abstract.csproj +++ b/HrynCo.NotificationService.DAL.Abstract/HrynCo.NotificationService.DAL.Abstract.csproj @@ -1,13 +1,13 @@  - - - - net10.0 enable enable + + + + diff --git a/HrynCo.NotificationService.DAL.Abstract/Providers/EmailChannel.cs b/HrynCo.NotificationService.DAL.Abstract/Providers/EmailChannel.cs index 3021251..5b1d1c6 100644 --- a/HrynCo.NotificationService.DAL.Abstract/Providers/EmailChannel.cs +++ b/HrynCo.NotificationService.DAL.Abstract/Providers/EmailChannel.cs @@ -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 18ac01f..c4d120d 100644 --- a/HrynCo.NotificationService.DAL.Abstract/Providers/EmailChannelUsage.cs +++ b/HrynCo.NotificationService.DAL.Abstract/Providers/EmailChannelUsage.cs @@ -1,13 +1,14 @@ -using HrynCo.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.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 f6a557a..0000000 --- a/HrynCo.NotificationService.DAL.EF/Core/UnitOfWork.cs +++ /dev/null @@ -1,10 +0,0 @@ -using HrynCo.DAL.EF.Core; - -namespace HrynCo.NotificationService.DAL.EF.Core; - -internal sealed class UnitOfWork : EfUnitOfWork -{ - public UnitOfWork(NotificationDbContext context) : base(context) - { - } -} diff --git a/HrynCo.NotificationService.DAL.EF/HrynCo.NotificationService.DAL.EF.csproj b/HrynCo.NotificationService.DAL.EF/HrynCo.NotificationService.DAL.EF.csproj index 5b7530d..216a1df 100644 --- a/HrynCo.NotificationService.DAL.EF/HrynCo.NotificationService.DAL.EF.csproj +++ b/HrynCo.NotificationService.DAL.EF/HrynCo.NotificationService.DAL.EF.csproj @@ -6,7 +6,6 @@ - runtime; build; native; contentfiles; analyzers; buildtransitive all 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 c7cbf3a..8234425 100644 --- a/HrynCo.NotificationService.DAL.EF/Repositories/EmailChannelRepository.cs +++ b/HrynCo.NotificationService.DAL.EF/Repositories/EmailChannelRepository.cs @@ -1,13 +1,13 @@ +namespace HrynCo.NotificationService.DAL.EF.Repositories; + using System.Text.Json; using HrynCo.NotificationService.DAL.Abstract.Providers; using HrynCo.NotificationService.DAL.Abstract.Repositories; -using HrynCo.DAL.EF.Core; +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> 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> 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 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 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 62ab9a5..f8c38fb 100644 --- a/HrynCo.NotificationService.DAL.EF/Repositories/EmailChannelUsageRepository.cs +++ b/HrynCo.NotificationService.DAL.EF/Repositories/EmailChannelUsageRepository.cs @@ -1,11 +1,11 @@ using HrynCo.NotificationService.DAL.Abstract.Repositories; -using HrynCo.DAL.EF.Core; +using HrynCo.NotificationService.DAL.EF.Core; using HrynCo.NotificationService.DAL.EF.Entities; 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 6bf0e25..32b1063 100644 --- a/HrynCo.NotificationService.DAL.EF/Repositories/EmailTemplateRepository.cs +++ b/HrynCo.NotificationService.DAL.EF/Repositories/EmailTemplateRepository.cs @@ -1,12 +1,13 @@ using HrynCo.NotificationService.DAL.Abstract.Repositories; using HrynCo.NotificationService.DAL.Abstract.Templates; -using HrynCo.DAL.EF.Core; +using HrynCo.NotificationService.DAL.EF.Core; using HrynCo.NotificationService.DAL.EF.Entities; 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) { @@ -14,7 +15,7 @@ internal sealed class EmailTemplateRepository : EfRepository> GetAllAsync(CancellationToken ct = default) { - List entities = await DbSet + List entities = await EfRepository.Get() .AsNoTracking() .ToListAsync(ct); return entities.Select(MapToDomain).ToList(); @@ -22,7 +23,7 @@ internal sealed class EmailTemplateRepository : EfRepository> GetByServiceAsync(string serviceName, CancellationToken ct = default) { - List entities = await DbSet + List entities = await EfRepository.Get() .AsNoTracking() .Where(x => x.ServiceName == serviceName) .ToListAsync(ct); @@ -32,7 +33,7 @@ internal sealed class EmailTemplateRepository : EfRepository GetAsync(string serviceName, string key, string languageCode, CancellationToken ct = default) { - EmailTemplateEntity? entity = await DbSet + EmailTemplateEntity? entity = await EfRepository.Get() .AsNoTracking() .FirstOrDefaultAsync( x => x.ServiceName == serviceName && x.Key == key && x.LanguageCode == languageCode, ct); @@ -40,22 +41,24 @@ internal sealed class EmailTemplateRepository : EfRepository - base.AddAsync(MapToEntity(EmailTemplate), ct); + public Task AddAsync(EmailTemplate EmailTemplate, CancellationToken ct = default) + { + return EfRepository.AddAsync(MapToEntity(EmailTemplate)); + } public Task UpdateAsync(EmailTemplate EmailTemplate, CancellationToken ct = default) { EmailTemplateEntity entity = MapToEntity(EmailTemplate); entity.Updated = DateTimeOffset.UtcNow; - Update(entity); - return Task.CompletedTask; + return EfRepository.UpdateAsync(entity); } 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() diff --git a/HrynCo.NotificationService.DAL.EF/ServiceCollectionExtensions.cs b/HrynCo.NotificationService.DAL.EF/ServiceCollectionExtensions.cs index 10a9a7d..0587f4e 100644 --- a/HrynCo.NotificationService.DAL.EF/ServiceCollectionExtensions.cs +++ b/HrynCo.NotificationService.DAL.EF/ServiceCollectionExtensions.cs @@ -16,10 +16,10 @@ public static class ServiceCollectionExtensions services.AddDbContext(options => options.UseNpgsql(connectionString)); - services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); return services; } diff --git a/HrynCo.NotificationService.Services/Core/RequestHandler.cs b/HrynCo.NotificationService.Services/Core/RequestHandler.cs index 7fb281d..54e7c2c 100644 --- a/HrynCo.NotificationService.Services/Core/RequestHandler.cs +++ b/HrynCo.NotificationService.Services/Core/RequestHandler.cs @@ -1,4 +1,3 @@ -using HrynCo.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 d3d584b..ef3ff21 100644 --- a/HrynCo.NotificationService.Services/EmailChannels/Create/CreateEmailChannelHandler.cs +++ b/HrynCo.NotificationService.Services/EmailChannels/Create/CreateEmailChannelHandler.cs @@ -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 785af9f..c1d64a9 100644 --- a/HrynCo.NotificationService.Services/EmailChannels/Delete/DeleteEmailChannelHandler.cs +++ b/HrynCo.NotificationService.Services/EmailChannels/Delete/DeleteEmailChannelHandler.cs @@ -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 6276aa6..867fd2d 100644 --- a/HrynCo.NotificationService.Services/EmailChannels/Get/GetEmailChannelHandler.cs +++ b/HrynCo.NotificationService.Services/EmailChannels/Get/GetEmailChannelHandler.cs @@ -1,4 +1,3 @@ -using HrynCo.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 65fa009..51cc638 100644 --- a/HrynCo.NotificationService.Services/EmailChannels/GetAll/GetAllEmailChannelsHandler.cs +++ b/HrynCo.NotificationService.Services/EmailChannels/GetAll/GetAllEmailChannelsHandler.cs @@ -1,4 +1,3 @@ -using HrynCo.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 74b0870..e2dc157 100644 --- a/HrynCo.NotificationService.Services/EmailChannels/GetByService/GetEmailChannelsHandler.cs +++ b/HrynCo.NotificationService.Services/EmailChannels/GetByService/GetEmailChannelsHandler.cs @@ -1,4 +1,3 @@ -using HrynCo.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 95f19f9..57c76d8 100644 --- a/HrynCo.NotificationService.Services/EmailChannels/GetUsageSummary/GetChannelUsageSummaryHandler.cs +++ b/HrynCo.NotificationService.Services/EmailChannels/GetUsageSummary/GetChannelUsageSummaryHandler.cs @@ -1,4 +1,3 @@ -using HrynCo.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 e79ff1e..ed5a9c5 100644 --- a/HrynCo.NotificationService.Services/EmailChannels/Send/SendEmailHandler.cs +++ b/HrynCo.NotificationService.Services/EmailChannels/Send/SendEmailHandler.cs @@ -1,6 +1,5 @@ using System.Net; using System.Net.Mail; -using HrynCo.DAL.Abstract; using HrynCo.NotificationService.DAL.Abstract.Providers; using HrynCo.NotificationService.DAL.Abstract.Repositories; using HrynCo.NotificationService.Services.Core; @@ -17,10 +16,9 @@ internal sealed class SendEmailHandler public SendEmailHandler( IContextualSerilogLogger logger, - IUnitOfWork unitOfWork, IEmailChannelRepository channels, IEmailChannelUsageRepository usage) - : base(logger, unitOfWork) + : base(logger) { _channels = channels; _usage = usage; diff --git a/HrynCo.NotificationService.Services/EmailChannels/TestSmtp/TestSmtpHandler.cs b/HrynCo.NotificationService.Services/EmailChannels/TestSmtp/TestSmtpHandler.cs index ba03b3d..573a9ee 100644 --- a/HrynCo.NotificationService.Services/EmailChannels/TestSmtp/TestSmtpHandler.cs +++ b/HrynCo.NotificationService.Services/EmailChannels/TestSmtp/TestSmtpHandler.cs @@ -1,6 +1,5 @@ using System.Net; using System.Net.Mail; -using HrynCo.DAL.Abstract; using HrynCo.NotificationService.Services.Core; using HrynCo.NotificationService.Services.Logging; using static HrynCo.NotificationService.Services.Core.ServiceResultHelper; @@ -11,9 +10,8 @@ internal sealed class TestSmtpHandler : RequestHandler> { public TestSmtpHandler( - IContextualSerilogLogger logger, - IUnitOfWork unitOfWork) - : base(logger, unitOfWork) + IContextualSerilogLogger logger) + : base(logger) { } diff --git a/HrynCo.NotificationService.Services/EmailChannels/Update/UpdateEmailChannelHandler.cs b/HrynCo.NotificationService.Services/EmailChannels/Update/UpdateEmailChannelHandler.cs index fdd654f..0f4da77 100644 --- a/HrynCo.NotificationService.Services/EmailChannels/Update/UpdateEmailChannelHandler.cs +++ b/HrynCo.NotificationService.Services/EmailChannels/Update/UpdateEmailChannelHandler.cs @@ -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 269b4d6..7800374 100644 --- a/HrynCo.NotificationService.Services/EmailTemplates/Create/CreateEmailTemplateHandler.cs +++ b/HrynCo.NotificationService.Services/EmailTemplates/Create/CreateEmailTemplateHandler.cs @@ -1,4 +1,3 @@ -using HrynCo.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 da4e960..b22b3c6 100644 --- a/HrynCo.NotificationService.Services/EmailTemplates/Delete/DeleteEmailTemplateHandler.cs +++ b/HrynCo.NotificationService.Services/EmailTemplates/Delete/DeleteEmailTemplateHandler.cs @@ -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 e685672..70819aa 100644 --- a/HrynCo.NotificationService.Services/EmailTemplates/Get/GetEmailTemplateHandler.cs +++ b/HrynCo.NotificationService.Services/EmailTemplates/Get/GetEmailTemplateHandler.cs @@ -1,4 +1,3 @@ -using HrynCo.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 5a87f86..ac762f5 100644 --- a/HrynCo.NotificationService.Services/EmailTemplates/GetAll/GetAllEmailTemplatesHandler.cs +++ b/HrynCo.NotificationService.Services/EmailTemplates/GetAll/GetAllEmailTemplatesHandler.cs @@ -1,4 +1,3 @@ -using HrynCo.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; } diff --git a/HrynCo.NotificationService.Services/EmailTemplates/GetByService/GetEmailTemplatesHandler.cs b/HrynCo.NotificationService.Services/EmailTemplates/GetByService/GetEmailTemplatesHandler.cs index 979da8d..63cf086 100644 --- a/HrynCo.NotificationService.Services/EmailTemplates/GetByService/GetEmailTemplatesHandler.cs +++ b/HrynCo.NotificationService.Services/EmailTemplates/GetByService/GetEmailTemplatesHandler.cs @@ -1,4 +1,3 @@ -using HrynCo.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 a9d101a..1cd842d 100644 --- a/HrynCo.NotificationService.Services/EmailTemplates/Update/UpdateEmailTemplateHandler.cs +++ b/HrynCo.NotificationService.Services/EmailTemplates/Update/UpdateEmailTemplateHandler.cs @@ -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/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/Views/AdminTemplates/Edit.cshtml b/HrynCo.NotificationService.Web/Views/AdminTemplates/Edit.cshtml index 891e163..5171b6e 100644 --- a/HrynCo.NotificationService.Web/Views/AdminTemplates/Edit.cshtml +++ b/HrynCo.NotificationService.Web/Views/AdminTemplates/Edit.cshtml @@ -21,74 +21,93 @@ } -
-
- - - -
-
- - - -
-
- - - -
-
+ -
- - - -
- -
- - - -
- -
- - -
- -
- - - -
JSON array of {"name":"...", "required":true|false}
-
- -
-
-
-
Preview
-
Rendered with sample values from the variable list.
-
- Ready -
- -
-
-
Sample values
-
-
Change these values to see the rendered output update immediately.
+
+
+
+
+ + + +
+
+ + + +
+
+ + + +
-
-
Rendered subject
-
+
+ + + +
-
Rendered HTML
- +
+ + + +
-
Rendered text
-

+            
+ + +
+ +
+ + + +
JSON array of {"name":"...", "required":true|false}
+
+
+ +
+
+
+
+
Preview
+
Rendered with sample values from the variable list.
+
+ Ready +
+ +
+
+
Sample values
+
+
Change these values to see the rendered output update immediately.
+
+ +
+
Rendered subject
+
+ +
Rendered HTML
+ + +
Rendered text
+

+                    
+
@@ -114,6 +133,7 @@ const previewText = document.getElementById('previewText'); const previewFrame = document.getElementById('previewHtmlFrame'); const previewStatus = document.getElementById('previewStatus'); + const previewTab = document.getElementById('preview-tab'); if (!subjectField || !htmlField || !textField || !variablesField || !previewVariablesHost || !previewSubject || !previewText || !previewFrame || !previewStatus) { return; @@ -267,6 +287,9 @@ subjectField.addEventListener('input', updatePreview); htmlField.addEventListener('input', updatePreview); textField.addEventListener('input', updatePreview); + if (previewTab) { + previewTab.addEventListener('shown.bs.tab', updatePreview); + } renderVariableInputs(); })(); diff --git a/HrynCo.NotificationService.Web/wwwroot/css/admin.css b/HrynCo.NotificationService.Web/wwwroot/css/admin.css index 40bbb6d..eb8a068 100644 --- a/HrynCo.NotificationService.Web/wwwroot/css/admin.css +++ b/HrynCo.NotificationService.Web/wwwroot/css/admin.css @@ -118,9 +118,10 @@ body { .empty-state .bi { font-size: 2.5rem; opacity: .35; display: block; margin-bottom: .75rem; } .empty-state p { font-size: .9rem; margin-bottom: 0; } -/* ── Editor wrapper — constrains width ───────────────── */ +/* ── Editor wrapper ──────────────────────────────────── */ .editor-wrapper { - max-width: 860px; + width: 100%; + max-width: none; } /* ── Editor card ──────────────────────────────────────── */ @@ -154,6 +155,26 @@ body { border-radius: 0 0 .5rem .5rem !important; } +/* ── Editor tabs ─────────────────────────────────────── */ +.template-editor-tabs { + border-bottom-color: #dce3eb; +} + +.template-editor-tabs .nav-link { + color: #526072; + font-weight: 600; + border-radius: .5rem .5rem 0 0; +} + +.template-editor-tabs .nav-link.active { + color: #0d6efd; +} + +/* ── Tab content ──────────────────────────────────────── */ +.tab-content { + min-width: 0; +} + /* ── Email template preview ───────────────────────────── */ .template-preview-panel { border: 1px solid #dce3eb; @@ -185,9 +206,9 @@ body { margin-top: .15rem; } -.template-preview-grid { +.template-preview-body { display: grid; - grid-template-columns: minmax(240px, 300px) minmax(0, 1fr); + grid-template-columns: minmax(0, 1fr); gap: 1rem; padding: 1rem 1.25rem 1.25rem; } @@ -197,6 +218,13 @@ body { min-width: 0; } +.template-preview-section-block { + padding: 1rem; + border: 1px solid #e5ebf2; + border-radius: .65rem; + background: #fafcff; +} + .template-preview-section-title { font-size: .7rem; font-weight: 700; @@ -210,6 +238,7 @@ body { display: flex; flex-direction: column; gap: .75rem; + max-width: 420px; } .template-preview-variables .form-control-sm { @@ -248,7 +277,7 @@ body { } @media (max-width: 992px) { - .template-preview-grid { + .template-preview-body { grid-template-columns: 1fr; } -- 2.52.0 From c18f0b7fb18676af0110f5b0163b6f0ddd09e74c Mon Sep 17 00:00:00 2001 From: Anatolii Grynchuk Date: Wed, 13 May 2026 02:11:59 +0300 Subject: [PATCH 16/26] refactor: update admin CSS for improved layout and readability - Switch `.template-preview-variables` to grid layout for better responsiveness. - Adjust form label font size and spacing for consistency. - Enhance styles of `.form-control-sm` for improved usability. --- .../wwwroot/css/admin.css | 20 +++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/HrynCo.NotificationService.Web/wwwroot/css/admin.css b/HrynCo.NotificationService.Web/wwwroot/css/admin.css index eb8a068..f02c088 100644 --- a/HrynCo.NotificationService.Web/wwwroot/css/admin.css +++ b/HrynCo.NotificationService.Web/wwwroot/css/admin.css @@ -235,14 +235,26 @@ body { } .template-preview-variables { - display: flex; - flex-direction: column; - gap: .75rem; - max-width: 420px; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: .75rem 1rem; + max-width: none; +} + +.template-preview-variables > div { + margin-bottom: 0 !important; +} + +.template-preview-variables .form-label { + font-size: .72rem; + margin-bottom: .2rem; } .template-preview-variables .form-control-sm { background: #fff; + min-height: calc(1.5em + .45rem + 2px); + padding: .2rem .45rem; + font-size: .82rem; } .template-preview-subject { -- 2.52.0 From 0861e18cecb99143e272d8ba5867954a4bd2976a Mon Sep 17 00:00:00 2001 From: Anatolii Grynchuk Date: Wed, 13 May 2026 03:39:44 +0300 Subject: [PATCH 17/26] refactor: improve UpdateAsync in EmailTemplateRepository for better entity handling - Add null check and fetch entity before updating fields. - Replace the direct property assignment with detailed updates. - Ensure changes are saved using SaveChangesAsync. --- .../Repositories/EmailTemplateRepository.cs | 24 +++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/HrynCo.NotificationService.DAL.EF/Repositories/EmailTemplateRepository.cs b/HrynCo.NotificationService.DAL.EF/Repositories/EmailTemplateRepository.cs index 32b1063..ec2d727 100644 --- a/HrynCo.NotificationService.DAL.EF/Repositories/EmailTemplateRepository.cs +++ b/HrynCo.NotificationService.DAL.EF/Repositories/EmailTemplateRepository.cs @@ -46,11 +46,27 @@ internal sealed class EmailTemplateRepository return EfRepository.AddAsync(MapToEntity(EmailTemplate)); } - public Task UpdateAsync(EmailTemplate EmailTemplate, CancellationToken ct = default) + public async Task UpdateAsync(EmailTemplate EmailTemplate, CancellationToken ct = default) { - EmailTemplateEntity entity = MapToEntity(EmailTemplate); - entity.Updated = DateTimeOffset.UtcNow; - return EfRepository.UpdateAsync(entity); + 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) -- 2.52.0 From 25fb48ccf048ed4ce30778292e59a0bb00d68456 Mon Sep 17 00:00:00 2001 From: Anatolii Grynchuk Date: Thu, 14 May 2026 22:15:15 +0300 Subject: [PATCH 18/26] refactor: modularize email processing logic and improve service structure - Extract email template handling, rendering, and sending code into `Worker.Services` project. - Introduce `EmailTemplateService`, `EmailTemplateRenderingService`, and `SendEmailService`. - Simplify consumer logic by delegating to scoped services. - Update project dependencies and package references accordingly. --- .../EmailChannels/Send/SendEmailHandler.cs | 13 +- .../EmailTemplateRenderingService.cs | 24 +++ .../EmailProcessing/EmailTemplateService.cs | 31 +++ .../IEmailTemplateRenderingService.cs | 9 + .../EmailProcessing/IEmailTemplateService.cs | 12 ++ .../EmailProcessing/ISendEmailService.cs | 8 + .../EmailProcessing}/RenderedEmail.cs | 2 +- .../EmailProcessing/SendEmailService.cs | 198 ++++++++++++++++++ ...NotificationService.Worker.Services.csproj | 21 ++ .../ServiceCollectionExtensions.cs | 17 ++ .../AppSettings.cs | 2 +- HrynCo.NotificationService.Worker/Dockerfile | 4 +- .../HrynCo.NotificationService.Worker.csproj | 3 +- HrynCo.NotificationService.Worker/Program.cs | 7 +- .../SendEmailConsumer.cs | 176 ++-------------- .../appsettings.Development.json | 24 +++ HrynCo.NotificationService.slnx | 1 + README.md | 16 ++ 18 files changed, 394 insertions(+), 174 deletions(-) create mode 100644 HrynCo.NotificationService.Worker.Services/EmailProcessing/EmailTemplateRenderingService.cs create mode 100644 HrynCo.NotificationService.Worker.Services/EmailProcessing/EmailTemplateService.cs create mode 100644 HrynCo.NotificationService.Worker.Services/EmailProcessing/IEmailTemplateRenderingService.cs create mode 100644 HrynCo.NotificationService.Worker.Services/EmailProcessing/IEmailTemplateService.cs create mode 100644 HrynCo.NotificationService.Worker.Services/EmailProcessing/ISendEmailService.cs rename {HrynCo.NotificationService.Worker => HrynCo.NotificationService.Worker.Services/EmailProcessing}/RenderedEmail.cs (53%) create mode 100644 HrynCo.NotificationService.Worker.Services/EmailProcessing/SendEmailService.cs create mode 100644 HrynCo.NotificationService.Worker.Services/HrynCo.NotificationService.Worker.Services.csproj create mode 100644 HrynCo.NotificationService.Worker.Services/ServiceCollectionExtensions.cs diff --git a/HrynCo.NotificationService.Services/EmailChannels/Send/SendEmailHandler.cs b/HrynCo.NotificationService.Services/EmailChannels/Send/SendEmailHandler.cs index ed5a9c5..1d0426b 100644 --- a/HrynCo.NotificationService.Services/EmailChannels/Send/SendEmailHandler.cs +++ b/HrynCo.NotificationService.Services/EmailChannels/Send/SendEmailHandler.cs @@ -1,5 +1,6 @@ using System.Net; using System.Net.Mail; +using System.Text; using HrynCo.NotificationService.DAL.Abstract.Providers; using HrynCo.NotificationService.DAL.Abstract.Repositories; using HrynCo.NotificationService.Services.Core; @@ -48,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.Worker.Services/EmailProcessing/EmailTemplateRenderingService.cs b/HrynCo.NotificationService.Worker.Services/EmailProcessing/EmailTemplateRenderingService.cs new file mode 100644 index 0000000..c6eebaa --- /dev/null +++ b/HrynCo.NotificationService.Worker.Services/EmailProcessing/EmailTemplateRenderingService.cs @@ -0,0 +1,24 @@ +namespace HrynCo.NotificationService.Worker.Services.EmailProcessing; + +using System.Text; +using HrynCo.NotificationService.Contracts.Messages; +using HrynCo.NotificationService.DAL.Abstract.Templates; + +internal sealed class EmailTemplateRenderingService : IEmailTemplateRenderingService +{ + public RenderedEmail Render(EmailTemplate template, SendEmailMessageData data) + { + return new RenderedEmail( + Interpolate(template.Subject, data.Variables), + Interpolate(template.HtmlBody, data.Variables), + Interpolate(template.TextBody, data.Variables)); + } + + private static string Interpolate(string text, IReadOnlyDictionary variables) + { + var sb = new StringBuilder(text); + foreach (var (key, value) in variables) + sb.Replace($"{{{{{key}}}}}", value); + return sb.ToString(); + } +} diff --git a/HrynCo.NotificationService.Worker.Services/EmailProcessing/EmailTemplateService.cs b/HrynCo.NotificationService.Worker.Services/EmailProcessing/EmailTemplateService.cs new file mode 100644 index 0000000..fee311c --- /dev/null +++ b/HrynCo.NotificationService.Worker.Services/EmailProcessing/EmailTemplateService.cs @@ -0,0 +1,31 @@ +namespace HrynCo.NotificationService.Worker.Services.EmailProcessing; + +using HrynCo.NotificationService.DAL.Abstract.Repositories; +using HrynCo.NotificationService.DAL.Abstract.Templates; + +internal sealed class EmailTemplateService : IEmailTemplateService +{ + private readonly IEmailTemplateRepository _templateRepository; + + public EmailTemplateService(IEmailTemplateRepository templateRepository) + { + _templateRepository = templateRepository; + } + + public async Task GetAsync( + string serviceName, + string templateKey, + string? languageCode, + CancellationToken cancellationToken) + { + var lang = string.IsNullOrWhiteSpace(languageCode) ? "en" : languageCode; + var template = await _templateRepository.GetAsync(serviceName, templateKey, lang, cancellationToken); + + if (template is null && lang != "en") + template = await _templateRepository.GetAsync(serviceName, templateKey, "en", cancellationToken); + + return template + ?? throw new InvalidOperationException( + $"Template not found: service='{serviceName}' key='{templateKey}' language='{lang}'."); + } +} diff --git a/HrynCo.NotificationService.Worker.Services/EmailProcessing/IEmailTemplateRenderingService.cs b/HrynCo.NotificationService.Worker.Services/EmailProcessing/IEmailTemplateRenderingService.cs new file mode 100644 index 0000000..8c8501e --- /dev/null +++ b/HrynCo.NotificationService.Worker.Services/EmailProcessing/IEmailTemplateRenderingService.cs @@ -0,0 +1,9 @@ +using HrynCo.NotificationService.Contracts.Messages; +using HrynCo.NotificationService.DAL.Abstract.Templates; + +namespace HrynCo.NotificationService.Worker.Services.EmailProcessing; + +public interface IEmailTemplateRenderingService +{ + RenderedEmail Render(EmailTemplate template, SendEmailMessageData data); +} diff --git a/HrynCo.NotificationService.Worker.Services/EmailProcessing/IEmailTemplateService.cs b/HrynCo.NotificationService.Worker.Services/EmailProcessing/IEmailTemplateService.cs new file mode 100644 index 0000000..f388c41 --- /dev/null +++ b/HrynCo.NotificationService.Worker.Services/EmailProcessing/IEmailTemplateService.cs @@ -0,0 +1,12 @@ +using HrynCo.NotificationService.DAL.Abstract.Templates; + +namespace HrynCo.NotificationService.Worker.Services.EmailProcessing; + +public interface IEmailTemplateService +{ + Task GetAsync( + string serviceName, + string templateKey, + string? languageCode, + CancellationToken cancellationToken); +} diff --git a/HrynCo.NotificationService.Worker.Services/EmailProcessing/ISendEmailService.cs b/HrynCo.NotificationService.Worker.Services/EmailProcessing/ISendEmailService.cs new file mode 100644 index 0000000..c5436bd --- /dev/null +++ b/HrynCo.NotificationService.Worker.Services/EmailProcessing/ISendEmailService.cs @@ -0,0 +1,8 @@ +using HrynCo.NotificationService.Contracts.Messages; + +namespace HrynCo.NotificationService.Worker.Services.EmailProcessing; + +public interface ISendEmailService +{ + Task ProcessAsync(SendEmailMessage message, CancellationToken cancellationToken); +} diff --git a/HrynCo.NotificationService.Worker/RenderedEmail.cs b/HrynCo.NotificationService.Worker.Services/EmailProcessing/RenderedEmail.cs similarity index 53% rename from HrynCo.NotificationService.Worker/RenderedEmail.cs rename to HrynCo.NotificationService.Worker.Services/EmailProcessing/RenderedEmail.cs index fcb41bb..0f9637b 100644 --- a/HrynCo.NotificationService.Worker/RenderedEmail.cs +++ b/HrynCo.NotificationService.Worker.Services/EmailProcessing/RenderedEmail.cs @@ -1,3 +1,3 @@ -namespace HrynCo.NotificationService.Worker; +namespace HrynCo.NotificationService.Worker.Services.EmailProcessing; public record RenderedEmail(string Subject, string HtmlBody, string TextBody); diff --git a/HrynCo.NotificationService.Worker.Services/EmailProcessing/SendEmailService.cs b/HrynCo.NotificationService.Worker.Services/EmailProcessing/SendEmailService.cs new file mode 100644 index 0000000..0793ce1 --- /dev/null +++ b/HrynCo.NotificationService.Worker.Services/EmailProcessing/SendEmailService.cs @@ -0,0 +1,198 @@ +namespace HrynCo.NotificationService.Worker.Services.EmailProcessing; + +using System.Net; +using System.Net.Mail; +using System.Text; +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.RabbitMq; +using Microsoft.Extensions.Logging; + +internal sealed class SendEmailService : ISendEmailService +{ + private readonly IEmailChannelRepository _channelRepository; + private readonly IEmailChannelUsageRepository _usageRepository; + private readonly IEmailTemplateService _templateService; + private readonly IEmailTemplateRenderingService _templateRenderingService; + private readonly IRabbitMqPublisher _publisher; + private readonly ILogger _logger; + + public SendEmailService( + IEmailChannelRepository channelRepository, + IEmailChannelUsageRepository usageRepository, + IEmailTemplateService templateService, + IEmailTemplateRenderingService templateRenderingService, + IRabbitMqPublisher publisher, + ILogger logger) + { + _channelRepository = channelRepository; + _usageRepository = usageRepository; + _templateService = templateService; + _templateRenderingService = templateRenderingService; + _publisher = publisher; + _logger = logger; + } + + public async Task ProcessAsync(SendEmailMessage message, CancellationToken cancellationToken) + { + SendEmailMessageData data = message.Data; + + _logger.LogInformation( + "Processing SendEmail for service={Service} template={Template} recipient={Recipient} [CorrelationId={CorrelationId}]", + data.ServiceName, data.TemplateKey, data.RecipientEmail, message.CorrelationContext?.CorrelationId); + + EmailChannel channel = await ResolveChannelAsync(data.ServiceName, cancellationToken); + EmailTemplate template = await GetTemplateAsync(data, cancellationToken); + + await EnforceLimitsAsync(channel, cancellationToken); + + RenderedEmail rendered = _templateRenderingService.Render(template, data); + + SmtpChannelSettings smtpChannel = channel.Settings as SmtpChannelSettings + ?? throw new InvalidOperationException( + $"Channel type '{channel.EmailChannelType}' is not supported for sending."); + + try + { + using var client = new SmtpClient(smtpChannel.Host, smtpChannel.Port) + { + EnableSsl = smtpChannel.UseSsl, + Credentials = string.IsNullOrWhiteSpace(smtpChannel.Username) + ? null + : new NetworkCredential(smtpChannel.Username, smtpChannel.Password) + }; + + using var mail = new MailMessage + { + From = new MailAddress(smtpChannel.FromEmail, smtpChannel.FromName), + Subject = rendered.Subject, + Body = rendered.TextBody, + IsBodyHtml = false, + BodyEncoding = Encoding.UTF8, + SubjectEncoding = Encoding.UTF8 + }; + + if (!string.IsNullOrWhiteSpace(rendered.HtmlBody)) + { + var html = AlternateView.CreateAlternateViewFromString( + rendered.HtmlBody, Encoding.UTF8, "text/html"); + mail.AlternateViews.Add(html); + } + + mail.To.Add(new MailAddress(data.RecipientEmail, data.RecipientName)); + + await client.SendMailAsync(mail, cancellationToken); + } + catch (Exception ex) + { + _logger.LogError(ex, "SMTP send failed for channel {ChannelId}", channel.Id); + throw; + } + + await _usageRepository.IncrementUsageAsync( + channel.Id, + DateOnly.FromDateTime(DateTime.UtcNow), + cancellationToken); + + _logger.LogInformation( + "Email sent successfully service={Service} template={Template} recipient={Recipient}", + data.ServiceName, data.TemplateKey, data.RecipientEmail); + + await PublishResultAsync(message.CorrelationContext, data, null, cancellationToken); + } + + private async Task GetTemplateAsync(SendEmailMessageData data, CancellationToken cancellationToken) + { + return await _templateService.GetAsync( + data.ServiceName, + data.TemplateKey, + data.LanguageCode, + cancellationToken); + } + + private async Task ResolveChannelAsync(string serviceName, CancellationToken ct) + { + var channels = await _channelRepository.GetByServiceAsync(serviceName, ct); + + return channels + .Where(c => c.IsActive) + .OrderBy(c => c.Priority) + .FirstOrDefault() + ?? throw new InvalidOperationException( + $"No active email channel found for service '{serviceName}'."); + } + + private async Task EnforceLimitsAsync(EmailChannel channel, CancellationToken ct) + { + DateOnly today = DateOnly.FromDateTime(DateTime.UtcNow); + + if (channel.DailyLimit.HasValue) + { + int daily = await _usageRepository.GetDailyCountAsync(channel.Id, today, ct); + + if (daily >= channel.DailyLimit.Value) + { + throw new InvalidOperationException( + $"Channel '{channel.Id}' daily limit of {channel.DailyLimit.Value} reached ({daily} sent today)."); + } + } + + if (channel.MonthlyLimit.HasValue) + { + int monthly = await _usageRepository.GetMonthlyCountAsync(channel.Id, today.Year, today.Month, ct); + + if (monthly >= channel.MonthlyLimit.Value) + { + throw new InvalidOperationException( + $"Channel '{channel.Id}' monthly limit of {channel.MonthlyLimit.Value} reached ({monthly} sent this month)."); + } + } + } + + private async Task PublishResultAsync( + CorrelationContext? correlationContext, + SendEmailMessageData data, + string? errorMessage, + CancellationToken ct) + { + string? replyTo = correlationContext?.ReplyTo; + + if (string.IsNullOrWhiteSpace(replyTo)) + { + return; + } + + try + { + var result = new NotificationResultMessage + { + CorrelationContext = (correlationContext ?? new CorrelationContext + { + CorrelationId = Guid.NewGuid().ToString() + }) with + { + ReplyTo = null + }, + Data = new NotificationResultData + { + ServiceName = data.ServiceName, + RecipientEmail = data.RecipientEmail, + TemplateKey = data.TemplateKey, + Timestamp = DateTimeOffset.UtcNow, + ErrorMessage = errorMessage + } + }; + + await _publisher.PublishAsync(replyTo, result, ct); + + _logger.LogDebug("Result published to reply queue '{Queue}' [CorrelationId={CorrelationId}]", + replyTo, correlationContext?.CorrelationId); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to publish notification result to reply queue '{Queue}'", replyTo); + } + } +} diff --git a/HrynCo.NotificationService.Worker.Services/HrynCo.NotificationService.Worker.Services.csproj b/HrynCo.NotificationService.Worker.Services/HrynCo.NotificationService.Worker.Services.csproj new file mode 100644 index 0000000..d2d358d --- /dev/null +++ b/HrynCo.NotificationService.Worker.Services/HrynCo.NotificationService.Worker.Services.csproj @@ -0,0 +1,21 @@ + + + + net10.0 + enable + enable + HrynCo.NotificationService.Worker.Services + + + + + + + + + + + + + + diff --git a/HrynCo.NotificationService.Worker.Services/ServiceCollectionExtensions.cs b/HrynCo.NotificationService.Worker.Services/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..3dec0ea --- /dev/null +++ b/HrynCo.NotificationService.Worker.Services/ServiceCollectionExtensions.cs @@ -0,0 +1,17 @@ +using HrynCo.NotificationService.Worker.Services.EmailProcessing; +using Hrynco.RabbitMq; +using Microsoft.Extensions.DependencyInjection; + +namespace HrynCo.NotificationService.Worker.Services; + +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddNotificationWorkerServices(this IServiceCollection services) + { + services.AddSingleton(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + return services; + } +} diff --git a/HrynCo.NotificationService.Worker/AppSettings.cs b/HrynCo.NotificationService.Worker/AppSettings.cs index 86e7f3e..889572c 100644 --- a/HrynCo.NotificationService.Worker/AppSettings.cs +++ b/HrynCo.NotificationService.Worker/AppSettings.cs @@ -8,4 +8,4 @@ public sealed class AppSettings public string ConnectionString { get; init; } = string.Empty; public RabbitMqSettings RabbitMq { get; init; } = null!; -} \ No newline at end of file +} diff --git a/HrynCo.NotificationService.Worker/Dockerfile b/HrynCo.NotificationService.Worker/Dockerfile index df2e1ec..76cf967 100644 --- a/HrynCo.NotificationService.Worker/Dockerfile +++ b/HrynCo.NotificationService.Worker/Dockerfile @@ -11,7 +11,7 @@ COPY ["Directory.Build.props", "."] COPY ["Directory.Packages.props", "."] COPY ["HrynCo.NotificationService.DAL.Abstract/HrynCo.NotificationService.DAL.Abstract.csproj", "HrynCo.NotificationService.DAL.Abstract/"] COPY ["HrynCo.NotificationService.DAL.EF/HrynCo.NotificationService.DAL.EF.csproj", "HrynCo.NotificationService.DAL.EF/"] -COPY ["HrynCo.NotificationService.Services/HrynCo.NotificationService.Services.csproj", "HrynCo.NotificationService.Services/"] +COPY ["HrynCo.NotificationService.Worker.Services/HrynCo.NotificationService.Worker.Services.csproj", "HrynCo.NotificationService.Worker.Services/"] COPY ["HrynCo.NotificationService.Worker/HrynCo.NotificationService.Worker.csproj", "HrynCo.NotificationService.Worker/"] RUN dotnet restore "HrynCo.NotificationService.Worker/HrynCo.NotificationService.Worker.csproj" @@ -28,4 +28,4 @@ RUN dotnet publish "./HrynCo.NotificationService.Worker.csproj" -c $BUILD_CONFIG FROM base AS final WORKDIR /app COPY --from=publish /app/publish . -ENTRYPOINT ["dotnet", "HrynCo.NotificationService.Worker.dll"] \ No newline at end of file +ENTRYPOINT ["dotnet", "HrynCo.NotificationService.Worker.dll"] diff --git a/HrynCo.NotificationService.Worker/HrynCo.NotificationService.Worker.csproj b/HrynCo.NotificationService.Worker/HrynCo.NotificationService.Worker.csproj index 211d07b..c9199ce 100644 --- a/HrynCo.NotificationService.Worker/HrynCo.NotificationService.Worker.csproj +++ b/HrynCo.NotificationService.Worker/HrynCo.NotificationService.Worker.csproj @@ -10,7 +10,6 @@ - @@ -19,7 +18,7 @@ - + diff --git a/HrynCo.NotificationService.Worker/Program.cs b/HrynCo.NotificationService.Worker/Program.cs index 9f103eb..d9de6c9 100644 --- a/HrynCo.NotificationService.Worker/Program.cs +++ b/HrynCo.NotificationService.Worker/Program.cs @@ -1,8 +1,7 @@ using HrynCo.NotificationService.DAL.EF; -using HrynCo.NotificationService.Services; using HrynCo.NotificationService.Worker; +using HrynCo.NotificationService.Worker.Services; using Hrynco.RabbitMq; -using Microsoft.Extensions.Options; var builder = Host.CreateApplicationBuilder(args); @@ -14,7 +13,7 @@ var appSettings = builder.Configuration builder.Services.AddSingleton(appSettings); builder.Services.AddNotificationDataAccess(appSettings.ConnectionString); -builder.Services.AddNotificationServices(); +builder.Services.AddNotificationWorkerServices(); builder.Services.Configure( builder.Configuration.GetSection($"{AppSettings.SectionName}:RabbitMq")); @@ -22,4 +21,4 @@ builder.Services.Configure( builder.Services.AddHostedService(); var host = builder.Build(); -host.Run(); \ No newline at end of file +host.Run(); diff --git a/HrynCo.NotificationService.Worker/SendEmailConsumer.cs b/HrynCo.NotificationService.Worker/SendEmailConsumer.cs index 4e87250..ec3cbe1 100644 --- a/HrynCo.NotificationService.Worker/SendEmailConsumer.cs +++ b/HrynCo.NotificationService.Worker/SendEmailConsumer.cs @@ -1,174 +1,32 @@ namespace HrynCo.NotificationService.Worker; -using System.Text; -using System.Text.Json; 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.Services.EmailChannels.Send; using Hrynco.RabbitMq; -using MediatR; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; -using RabbitMQ.Client; +using HrynCo.NotificationService.Worker.Services.EmailProcessing; -public sealed class SendEmailConsumer( - IOptionsMonitor options, - IEmailChannelRepository channelRepository, - IEmailTemplateRepository templateRepository, - IEmailChannelUsageRepository usageRepository, - IMediator mediator, - AppSettings appSettings, - ILogger logger) - : RabbitMqConsumerBase(options, logger) +public sealed class SendEmailConsumer : RabbitMqConsumerBase { + private readonly IServiceScopeFactory _scopeFactory; + + public SendEmailConsumer( + IOptionsMonitor options, + IServiceScopeFactory scopeFactory, + ILogger logger) + : base(options, logger) + { + _scopeFactory = scopeFactory; + } + private const string IncomingQueue = "notification.send-email"; protected override string QueueName => IncomingQueue; protected override async Task HandleMessageAsync(SendEmailMessage message, CancellationToken cancellationToken) { - var data = message.Data; - - logger.LogInformation( - "Processing SendEmail for service={Service} template={Template} recipient={Recipient} [CorrelationId={CorrelationId}]", - data.ServiceName, data.TemplateKey, data.RecipientEmail, message.CorrelationContext?.CorrelationId); - - var channel = await ResolveChannelAsync(data.ServiceName, cancellationToken); - var template = await ResolveTemplateAsync(data.ServiceName, data.TemplateKey, data.LanguageCode, cancellationToken); - - await EnforceLimitsAsync(channel, cancellationToken); - - var rendered = RenderTemplate(template, data); - - var sendResult = await mediator.Send( - new SendEmailCommand(channel.Id, data.RecipientEmail, data.RecipientName, - rendered.Subject, rendered.HtmlBody, rendered.TextBody), - cancellationToken); - - if (!sendResult.IsSuccess) - throw new InvalidOperationException(sendResult.Error?.Message ?? "Send failed."); - - logger.LogInformation( - "Email sent successfully service={Service} template={Template} recipient={Recipient}", - data.ServiceName, data.TemplateKey, data.RecipientEmail); - - await PublishResultAsync(message.CorrelationContext, data, errorMessage: null, cancellationToken); - } - - private async Task ResolveChannelAsync(string serviceName, CancellationToken ct) - { - var channels = await channelRepository.GetByServiceAsync(serviceName, ct); - - return channels - .Where(c => c.IsActive) - .OrderBy(c => c.Priority) - .FirstOrDefault() - ?? throw new InvalidOperationException( - $"No active email channel found for service '{serviceName}'."); - } - - private async Task ResolveTemplateAsync( - string serviceName, string templateKey, string? languageCode, CancellationToken ct) - { - var lang = string.IsNullOrWhiteSpace(languageCode) ? "en" : languageCode; - var template = await templateRepository.GetAsync(serviceName, templateKey, lang, ct); - - if (template is null && lang != "en") - template = await templateRepository.GetAsync(serviceName, templateKey, "en", ct); - - return template - ?? throw new InvalidOperationException( - $"Template not found: service='{serviceName}' key='{templateKey}' language='{lang}'."); - } - - private async Task EnforceLimitsAsync(EmailChannel channel, CancellationToken ct) - { - var today = DateOnly.FromDateTime(DateTime.UtcNow); - - if (channel.DailyLimit.HasValue) - { - var daily = await usageRepository.GetDailyCountAsync(channel.Id, today, ct); - if (daily >= channel.DailyLimit.Value) - throw new InvalidOperationException( - $"Channel '{channel.Id}' daily limit of {channel.DailyLimit.Value} reached ({daily} sent today)."); - } - - if (channel.MonthlyLimit.HasValue) - { - var monthly = await usageRepository.GetMonthlyCountAsync(channel.Id, today.Year, today.Month, ct); - if (monthly >= channel.MonthlyLimit.Value) - throw new InvalidOperationException( - $"Channel '{channel.Id}' monthly limit of {channel.MonthlyLimit.Value} reached ({monthly} sent this month)."); - } - } - - private static RenderedEmail RenderTemplate(EmailTemplate template, SendEmailMessageData data) - { - return new RenderedEmail( - Interpolate(template.Subject, data.Variables), - Interpolate(template.HtmlBody, data.Variables), - Interpolate(template.TextBody, data.Variables)); - } - - private static string Interpolate(string text, IReadOnlyDictionary variables) - { - var sb = new StringBuilder(text); - foreach (var (key, value) in variables) - sb.Replace($"{{{{{key}}}}}", value); - return sb.ToString(); - } - - private async Task PublishResultAsync( - CorrelationContext? correlationContext, - SendEmailMessageData data, - string? errorMessage, - CancellationToken ct) - { - var replyTo = correlationContext?.ReplyTo; - if (string.IsNullOrWhiteSpace(replyTo)) - return; - - try - { - var result = new NotificationResultMessage - { - CorrelationContext = (correlationContext ?? new CorrelationContext { CorrelationId = Guid.NewGuid().ToString() }) with { ReplyTo = null }, - Data = new NotificationResultData - { - ServiceName = data.ServiceName, - RecipientEmail = data.RecipientEmail, - TemplateKey = data.TemplateKey, - Timestamp = DateTimeOffset.UtcNow, - ErrorMessage = errorMessage - } - }; - - byte[] body = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(result)); - - var factory = new ConnectionFactory - { - HostName = appSettings.RabbitMq.Host, - Port = appSettings.RabbitMq.Port, - UserName = appSettings.RabbitMq.User, - Password = appSettings.RabbitMq.Password, - VirtualHost = appSettings.RabbitMq.VirtualHost - }; - - await using var conn = await factory.CreateConnectionAsync(ct); - await using var ch = await conn.CreateChannelAsync(cancellationToken: ct); - - await ch.QueueDeclareAsync(replyTo, durable: true, exclusive: false, autoDelete: false, - cancellationToken: ct); - await ch.BasicPublishAsync(exchange: string.Empty, routingKey: replyTo, body: body, - cancellationToken: ct); - - logger.LogDebug("Result published to reply queue '{Queue}' [CorrelationId={CorrelationId}]", - replyTo, correlationContext?.CorrelationId); - } - catch (Exception ex) - { - logger.LogWarning(ex, "Failed to publish notification result to reply queue '{Queue}'", replyTo); - } + using var scope = _scopeFactory.CreateScope(); + var service = scope.ServiceProvider.GetRequiredService(); + await service.ProcessAsync(message, cancellationToken); } } diff --git a/HrynCo.NotificationService.Worker/appsettings.Development.json b/HrynCo.NotificationService.Worker/appsettings.Development.json index 3ef00ad..bf71488 100644 --- a/HrynCo.NotificationService.Worker/appsettings.Development.json +++ b/HrynCo.NotificationService.Worker/appsettings.Development.json @@ -1,4 +1,14 @@ { + "App": { + "ConnectionString": "Host=192.168.2.121;Port=55435;Database=hrynco_ns_prod;Username=ns_user;Password=HAwS0c4A1QmH", + "RabbitMq": { + "Host": "192.168.2.121", + "Port": 5675, + "User": "ns_user", + "Password": "LN22mEWYdfCy", + "VirtualHost": "/" + } + }, "Serilog": { "MinimumLevel": { "Default": "Debug", @@ -7,6 +17,20 @@ "Microsoft.EntityFrameworkCore": "Information", "Microsoft.AspNetCore": "Information" } + }, + "WriteTo": [ + { "Name": "Console" }, + { + "Name": "Seq", + "Args": { + "serverUrl": "http://192.168.2.121:5341" + } + } + ], + "Enrich": [ "FromLogContext" ], + "Properties": { + "Application": "hrynco-notification-service-worker", + "Environment": "Development" } } } \ No newline at end of file diff --git a/HrynCo.NotificationService.slnx b/HrynCo.NotificationService.slnx index 1db5b44..c124eb8 100644 --- a/HrynCo.NotificationService.slnx +++ b/HrynCo.NotificationService.slnx @@ -20,5 +20,6 @@ + diff --git a/README.md b/README.md index 6387551..fd4f3e7 100644 --- a/README.md +++ b/README.md @@ -1 +1,17 @@ # hrynco-notification-service + +## Notification worker flow + +```mermaid +flowchart TD + A[Worker host starts] --> B[Load config and register services] + B --> C[Start SendEmailConsumer] + C --> D[Receive message from notification.send-email] + D --> E[Resolve SendEmailService] + E --> F[Pick channel and template] + F --> G[Render email content] + G --> H[Send via SMTP] + H --> I[Update usage counters] + I --> J[Optionally publish result to reply queue] + H -. failure .-> K[Log and rethrow] +``` -- 2.52.0 From 285cc6abb75aefcec7fb71525775e7a37e0598bb Mon Sep 17 00:00:00 2001 From: Anatolii Hrynchuk Date: Sun, 17 May 2026 11:18:05 +0300 Subject: [PATCH 19/26] refactor: simplify `Program.cs` and clean up `NuGet.Config` --- HrynCo.NotificationService.Web/Program.cs | 19 ++++++++----------- NuGet.Config | 3 --- 2 files changed, 8 insertions(+), 14 deletions(-) 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/NuGet.Config b/NuGet.Config index 46fd5b4..6873eb9 100644 --- a/NuGet.Config +++ b/NuGet.Config @@ -1,8 +1,5 @@ - - - -- 2.52.0 From 3381fcc2f82bdbb7dd1f318ea43d47aafed321fe Mon Sep 17 00:00:00 2001 From: Anatolii Hrynchuk Date: Sun, 17 May 2026 11:31:44 +0300 Subject: [PATCH 20/26] refactor: add ordering by `ServiceName` and `Key` in `GetAllAsync` method --- .../Repositories/EmailTemplateRepository.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/HrynCo.NotificationService.DAL.EF/Repositories/EmailTemplateRepository.cs b/HrynCo.NotificationService.DAL.EF/Repositories/EmailTemplateRepository.cs index ec2d727..148c3b1 100644 --- a/HrynCo.NotificationService.DAL.EF/Repositories/EmailTemplateRepository.cs +++ b/HrynCo.NotificationService.DAL.EF/Repositories/EmailTemplateRepository.cs @@ -16,6 +16,7 @@ internal sealed class EmailTemplateRepository public async Task> GetAllAsync(CancellationToken ct = default) { List entities = await EfRepository.Get() + .OrderBy(x => x.ServiceName).ThenBy(x => x.Key) .AsNoTracking() .ToListAsync(ct); return entities.Select(MapToDomain).ToList(); -- 2.52.0 From 07f536938fd8db55f80f3004011166d56777e42d Mon Sep 17 00:00:00 2001 From: Anatolii Hrynchuk Date: Sun, 17 May 2026 11:43:25 +0300 Subject: [PATCH 21/26] feat: add filtering by `ServiceName` and `Key` in email templates query and UI --- .../Repositories/IEmailTemplateRepository.cs | 4 +-- .../Repositories/EmailTemplateRepository.cs | 16 ++++++++-- .../GetAll/GetAllEmailTemplatesHandler.cs | 2 +- .../GetAll/GetAllEmailTemplatesQuery.cs | 3 +- .../Admin/AdminTemplatesController.cs | 7 +++-- .../EmailTemplatesController.cs | 5 ++-- .../Views/AdminTemplates/Index.cshtml | 29 +++++++++++++++++++ 7 files changed, 56 insertions(+), 10 deletions(-) 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.EF/Repositories/EmailTemplateRepository.cs b/HrynCo.NotificationService.DAL.EF/Repositories/EmailTemplateRepository.cs index 148c3b1..b7fe3a9 100644 --- a/HrynCo.NotificationService.DAL.EF/Repositories/EmailTemplateRepository.cs +++ b/HrynCo.NotificationService.DAL.EF/Repositories/EmailTemplateRepository.cs @@ -13,9 +13,21 @@ internal sealed class EmailTemplateRepository { } - public async Task> GetAllAsync(CancellationToken ct = default) + public async Task> GetAllAsync(string? serviceName = null, string? key = null, CancellationToken ct = default) { - List entities = await EfRepository.Get() + 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); diff --git a/HrynCo.NotificationService.Services/EmailTemplates/GetAll/GetAllEmailTemplatesHandler.cs b/HrynCo.NotificationService.Services/EmailTemplates/GetAll/GetAllEmailTemplatesHandler.cs index ac762f5..71954a2 100644 --- a/HrynCo.NotificationService.Services/EmailTemplates/GetAll/GetAllEmailTemplatesHandler.cs +++ b/HrynCo.NotificationService.Services/EmailTemplates/GetAll/GetAllEmailTemplatesHandler.cs @@ -22,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.Web/Controllers/Admin/AdminTemplatesController.cs b/HrynCo.NotificationService.Web/Controllers/Admin/AdminTemplatesController.cs index f25b98a..c421397 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."); 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/Views/AdminTemplates/Index.cshtml b/HrynCo.NotificationService.Web/Views/AdminTemplates/Index.cshtml index ccc62f6..d16bf52 100644 --- a/HrynCo.NotificationService.Web/Views/AdminTemplates/Index.cshtml +++ b/HrynCo.NotificationService.Web/Views/AdminTemplates/Index.cshtml @@ -2,6 +2,8 @@ @model IReadOnlyList @{ ViewData["Title"] = "Email Templates"; + var serviceNameFilter = ViewData["ServiceNameFilter"] as string ?? string.Empty; + var keyFilter = ViewData["KeyFilter"] as string ?? string.Empty; } +
+
+
+
+ + +
+
+ + +
+
+ + Clear +
+
+
+
+ @if (!ViewData.ModelState.IsValid) {
-- 2.52.0 From cc3857a4092260a8cf24524cb7dc1bcac85d8432 Mon Sep 17 00:00:00 2001 From: Anatolii Hrynchuk Date: Sun, 17 May 2026 13:53:37 +0300 Subject: [PATCH 22/26] feat: add persistence and restoration of email template filters in UI and backend --- .../Admin/AdminTemplatesController.cs | 32 +++++++-- .../ViewModels/EmailTemplateEditViewModel.cs | 2 + .../Views/AdminTemplates/Edit.cshtml | 4 +- .../Views/AdminTemplates/Index.cshtml | 71 +++++++++++++++++-- 4 files changed, 97 insertions(+), 12 deletions(-) diff --git a/HrynCo.NotificationService.Web/Controllers/Admin/AdminTemplatesController.cs b/HrynCo.NotificationService.Web/Controllers/Admin/AdminTemplatesController.cs index c421397..c8c35b6 100644 --- a/HrynCo.NotificationService.Web/Controllers/Admin/AdminTemplatesController.cs +++ b/HrynCo.NotificationService.Web/Controllers/Admin/AdminTemplatesController.cs @@ -40,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) @@ -63,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); @@ -127,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/Views/AdminTemplates/Edit.cshtml b/HrynCo.NotificationService.Web/Views/AdminTemplates/Edit.cshtml index 5171b6e..414e512 100644 --- a/HrynCo.NotificationService.Web/Views/AdminTemplates/Edit.cshtml +++ b/HrynCo.NotificationService.Web/Views/AdminTemplates/Edit.cshtml @@ -10,6 +10,8 @@ @Html.AntiForgeryToken() + + @if (!ViewData.ModelState.IsValid) { @@ -116,7 +118,7 @@ - + Cancel } diff --git a/HrynCo.NotificationService.Web/Views/AdminTemplates/Index.cshtml b/HrynCo.NotificationService.Web/Views/AdminTemplates/Index.cshtml index d16bf52..20907cb 100644 --- a/HrynCo.NotificationService.Web/Views/AdminTemplates/Index.cshtml +++ b/HrynCo.NotificationService.Web/Views/AdminTemplates/Index.cshtml @@ -4,18 +4,24 @@ ViewData["Title"] = "Email Templates"; var serviceNameFilter = ViewData["ServiceNameFilter"] as string ?? string.Empty; var keyFilter = ViewData["KeyFilter"] as string ?? string.Empty; + var filterQuery = string.IsNullOrWhiteSpace(serviceNameFilter) && string.IsNullOrWhiteSpace(keyFilter) + ? string.Empty + : $"?serviceNameFilter={Uri.EscapeDataString(serviceNameFilter)}&keyFilter={Uri.EscapeDataString(keyFilter)}"; + var listQuery = string.IsNullOrWhiteSpace(serviceNameFilter) && string.IsNullOrWhiteSpace(keyFilter) + ? string.Empty + : $"?serviceName={Uri.EscapeDataString(serviceNameFilter)}&key={Uri.EscapeDataString(keyFilter)}"; }
-
+
- Clear + Clear
@@ -50,6 +56,61 @@
} + + @if (Model is null || Model.Count == 0) {
@@ -85,13 +146,15 @@ else @t.LanguageCode @t.Subject - Edit
+ + @Html.AntiForgeryToken()