diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..a67c691
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,9 @@
+**/.git
+**/.vs
+**/.idea
+**/bin
+**/obj
+**/*.user
+**/*.suo
+**/TestResults
+NuGet.Config
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..0808c4a
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,482 @@
+## Ignore Visual Studio temporary files, build results, and
+## files generated by popular Visual Studio add-ons.
+##
+## Get latest from `dotnet new gitignore`
+
+# dotenv files
+.env
+
+# User-specific files
+*.rsuser
+*.suo
+*.user
+*.userosscache
+*.sln.docstates
+
+# User-specific files (MonoDevelop/Xamarin Studio)
+*.userprefs
+
+# Mono auto generated files
+mono_crash.*
+
+# Build results
+[Dd]ebug/
+[Dd]ebugPublic/
+[Rr]elease/
+[Rr]eleases/
+x64/
+x86/
+[Ww][Ii][Nn]32/
+[Aa][Rr][Mm]/
+[Aa][Rr][Mm]64/
+bld/
+[Bb]in/
+[Oo]bj/
+[Ll]og/
+[Ll]ogs/
+
+# Visual Studio 2015/2017 cache/options directory
+.vs/
+# Uncomment if you have tasks that create the project's static files in wwwroot
+#wwwroot/
+
+# Visual Studio 2017 auto generated files
+Generated\ Files/
+
+# MSTest test Results
+[Tt]est[Rr]esult*/
+[Bb]uild[Ll]og.*
+
+# NUnit
+*.VisualState.xml
+TestResult.xml
+nunit-*.xml
+
+# Build Results of an ATL Project
+[Dd]ebugPS/
+[Rr]eleasePS/
+dlldata.c
+
+# Benchmark Results
+BenchmarkDotNet.Artifacts/
+
+# .NET
+project.lock.json
+project.fragment.lock.json
+artifacts/
+
+# Tye
+.tye/
+
+# ASP.NET Scaffolding
+ScaffoldingReadMe.txt
+
+# StyleCop
+StyleCopReport.xml
+
+# Files built by Visual Studio
+*_i.c
+*_p.c
+*_h.h
+*.ilk
+*.meta
+*.obj
+*.iobj
+*.pch
+*.pdb
+*.ipdb
+*.pgc
+*.pgd
+*.rsp
+# but not Directory.Build.rsp, as it configures directory-level build defaults
+!Directory.Build.rsp
+*.sbr
+*.tlb
+*.tli
+*.tlh
+*.tmp
+*.tmp_proj
+*_wpftmp.csproj
+*.log
+*.tlog
+*.vspscc
+*.vssscc
+.builds
+*.pidb
+*.svclog
+*.scc
+
+# Chutzpah Test files
+_Chutzpah*
+
+# Visual C++ cache files
+ipch/
+*.aps
+*.ncb
+*.opendb
+*.opensdf
+*.sdf
+*.cachefile
+*.VC.db
+*.VC.VC.opendb
+
+# Visual Studio profiler
+*.psess
+*.vsp
+*.vspx
+*.sap
+
+# Visual Studio Trace Files
+*.e2e
+
+# TFS 2012 Local Workspace
+$tf/
+
+# Guidance Automation Toolkit
+*.gpState
+
+# ReSharper is a .NET coding add-in
+_ReSharper*/
+*.[Rr]e[Ss]harper
+*.DotSettings.user
+
+# TeamCity is a build add-in
+_TeamCity*
+
+# DotCover is a Code Coverage Tool
+*.dotCover
+
+# AxoCover is a Code Coverage Tool
+.axoCover/*
+!.axoCover/settings.json
+
+# Coverlet is a free, cross platform Code Coverage Tool
+coverage*.json
+coverage*.xml
+coverage*.info
+
+# Visual Studio code coverage results
+*.coverage
+*.coveragexml
+
+# NCrunch
+_NCrunch_*
+.*crunch*.local.xml
+nCrunchTemp_*
+
+# MightyMoose
+*.mm.*
+AutoTest.Net/
+
+# Web workbench (sass)
+.sass-cache/
+
+# Installshield output folder
+[Ee]xpress/
+
+# DocProject is a documentation generator add-in
+DocProject/buildhelp/
+DocProject/Help/*.HxT
+DocProject/Help/*.HxC
+DocProject/Help/*.hhc
+DocProject/Help/*.hhk
+DocProject/Help/*.hhp
+DocProject/Help/Html2
+DocProject/Help/html
+
+# Click-Once directory
+publish/
+
+# Publish Web Output
+*.[Pp]ublish.xml
+*.azurePubxml
+# Note: Comment the next line if you want to checkin your web deploy settings,
+# but database connection strings (with potential passwords) will be unencrypted
+*.pubxml
+*.publishproj
+
+# Microsoft Azure Web App publish settings. Comment the next line if you want to
+# checkin your Azure Web App publish settings, but sensitive information contained
+# in these scripts will be unencrypted
+PublishScripts/
+
+# NuGet Packages
+*.nupkg
+# NuGet Symbol Packages
+*.snupkg
+# The packages folder can be ignored because of Package Restore
+**/[Pp]ackages/*
+# except build/, which is used as an MSBuild target.
+!**/[Pp]ackages/build/
+# Uncomment if necessary however generally it will be regenerated when needed
+#!**/[Pp]ackages/repositories.config
+# NuGet v3's project.json files produces more ignorable files
+*.nuget.props
+*.nuget.targets
+
+# Microsoft Azure Build Output
+csx/
+*.build.csdef
+
+# Microsoft Azure Emulator
+ecf/
+rcf/
+
+# Windows Store app package directories and files
+AppPackages/
+BundleArtifacts/
+Package.StoreAssociation.xml
+_pkginfo.txt
+*.appx
+*.appxbundle
+*.appxupload
+
+# Visual Studio cache files
+# files ending in .cache can be ignored
+*.[Cc]ache
+# but keep track of directories ending in .cache
+!?*.[Cc]ache/
+
+# Others
+ClientBin/
+~$*
+*~
+*.dbmdl
+*.dbproj.schemaview
+*.jfm
+*.pfx
+*.publishsettings
+orleans.codegen.cs
+
+# Including strong name files can present a security risk
+# (https://github.com/github/gitignore/pull/2483#issue-259490424)
+#*.snk
+
+# Since there are multiple workflows, uncomment next line to ignore bower_components
+# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
+#bower_components/
+
+# RIA/Silverlight projects
+Generated_Code/
+
+# Backup & report files from converting an old project file
+# to a newer Visual Studio version. Backup files are not needed,
+# because we have git ;-)
+_UpgradeReport_Files/
+Backup*/
+UpgradeLog*.XML
+UpgradeLog*.htm
+ServiceFabricBackup/
+*.rptproj.bak
+
+# SQL Server files
+*.mdf
+*.ldf
+*.ndf
+
+# Business Intelligence projects
+*.rdl.data
+*.bim.layout
+*.bim_*.settings
+*.rptproj.rsuser
+*- [Bb]ackup.rdl
+*- [Bb]ackup ([0-9]).rdl
+*- [Bb]ackup ([0-9][0-9]).rdl
+
+# Microsoft Fakes
+FakesAssemblies/
+
+# GhostDoc plugin setting file
+*.GhostDoc.xml
+
+# Node.js Tools for Visual Studio
+.ntvs_analysis.dat
+node_modules/
+
+# Visual Studio 6 build log
+*.plg
+
+# Visual Studio 6 workspace options file
+*.opt
+
+# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
+*.vbw
+
+# Visual Studio 6 auto-generated project file (contains which files were open etc.)
+*.vbp
+
+# Visual Studio 6 workspace and project file (working project files containing files to include in project)
+*.dsw
+*.dsp
+
+# Visual Studio 6 technical files
+*.ncb
+*.aps
+
+# Visual Studio LightSwitch build output
+**/*.HTMLClient/GeneratedArtifacts
+**/*.DesktopClient/GeneratedArtifacts
+**/*.DesktopClient/ModelManifest.xml
+**/*.Server/GeneratedArtifacts
+**/*.Server/ModelManifest.xml
+_Pvt_Extensions
+
+# Paket dependency manager
+.paket/paket.exe
+paket-files/
+
+# FAKE - F# Make
+.fake/
+
+# CodeRush personal settings
+.cr/personal
+
+# Python Tools for Visual Studio (PTVS)
+__pycache__/
+*.pyc
+
+# Cake - Uncomment if you are using it
+# tools/**
+# !tools/packages.config
+
+# Tabs Studio
+*.tss
+
+# Telerik's JustMock configuration file
+*.jmconfig
+
+# BizTalk build output
+*.btp.cs
+*.btm.cs
+*.odx.cs
+*.xsd.cs
+
+# OpenCover UI analysis results
+OpenCover/
+
+# Azure Stream Analytics local run output
+ASALocalRun/
+
+# MSBuild Binary and Structured Log
+*.binlog
+
+# NVidia Nsight GPU debugger configuration file
+*.nvuser
+
+# MFractors (Xamarin productivity tool) working folder
+.mfractor/
+
+# Local History for Visual Studio
+.localhistory/
+
+# Visual Studio History (VSHistory) files
+.vshistory/
+
+# BeatPulse healthcheck temp database
+healthchecksdb
+
+# Backup folder for Package Reference Convert tool in Visual Studio 2017
+MigrationBackup/
+
+# Ionide (cross platform F# VS Code tools) working folder
+.ionide/
+
+# Fody - auto-generated XML schema
+FodyWeavers.xsd
+
+# VS Code files for those working on multiple tools
+.vscode/*
+!.vscode/settings.json
+!.vscode/tasks.json
+!.vscode/launch.json
+!.vscode/extensions.json
+*.code-workspace
+
+# Local History for Visual Studio Code
+.history/
+
+# Windows Installer files from build outputs
+*.cab
+*.msi
+*.msix
+*.msm
+*.msp
+
+# JetBrains Rider
+*.sln.iml
+.idea/
+
+##
+## Visual studio for Mac
+##
+
+
+# globs
+Makefile.in
+*.userprefs
+*.usertasks
+config.make
+config.status
+aclocal.m4
+install-sh
+autom4te.cache/
+*.tar.gz
+tarballs/
+test-results/
+
+# content below from: https://github.com/github/gitignore/blob/main/Global/macOS.gitignore
+# General
+.DS_Store
+.AppleDouble
+.LSOverride
+
+# Icon must end with two \r
+Icon
+
+
+# Thumbnails
+._*
+
+# Files that might appear in the root of a volume
+.DocumentRevisions-V100
+.fseventsd
+.Spotlight-V100
+.TemporaryItems
+.Trashes
+.VolumeIcon.icns
+.com.apple.timemachine.donotpresent
+
+# Directories potentially created on remote AFP share
+.AppleDB
+.AppleDesktop
+Network Trash Folder
+Temporary Items
+.apdisk
+
+# content below from: https://github.com/github/gitignore/blob/main/Global/Windows.gitignore
+# Windows thumbnail cache files
+Thumbs.db
+ehthumbs.db
+ehthumbs_vista.db
+
+# Dump file
+*.stackdump
+
+# Folder config file
+[Dd]esktop.ini
+
+# Recycle Bin used on file shares
+$RECYCLE.BIN/
+
+# Windows Installer files
+*.cab
+*.msi
+*.msix
+*.msm
+*.msp
+
+# Windows shortcuts
+*.lnk
+
+# Vim temporary swap files
+*.swp
diff --git a/Directory.Build.props b/Directory.Build.props
new file mode 100644
index 0000000..e42216a
--- /dev/null
+++ b/Directory.Build.props
@@ -0,0 +1,5 @@
+
+
+ true
+
+
diff --git a/Directory.Packages.props b/Directory.Packages.props
new file mode 100644
index 0000000..170d1c4
--- /dev/null
+++ b/Directory.Packages.props
@@ -0,0 +1,44 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/HrynCo.NotificationService.Contracts/HrynCo.NotificationService.Contracts.csproj b/HrynCo.NotificationService.Contracts/HrynCo.NotificationService.Contracts.csproj
new file mode 100644
index 0000000..ca9fe6d
--- /dev/null
+++ b/HrynCo.NotificationService.Contracts/HrynCo.NotificationService.Contracts.csproj
@@ -0,0 +1,13 @@
+
+
+
+ net10.0
+ enable
+ enable
+
+
+
+
+
+
+
diff --git a/HrynCo.NotificationService.Contracts/Messages/NotificationResultData.cs b/HrynCo.NotificationService.Contracts/Messages/NotificationResultData.cs
new file mode 100644
index 0000000..877389b
--- /dev/null
+++ b/HrynCo.NotificationService.Contracts/Messages/NotificationResultData.cs
@@ -0,0 +1,14 @@
+namespace HrynCo.NotificationService.Contracts.Messages;
+
+public record NotificationResultData
+{
+ public required string ServiceName { get; init; }
+ public required string RecipientEmail { get; init; }
+ public required string TemplateKey { get; init; }
+ public required DateTimeOffset Timestamp { get; init; }
+
+ /// Null when delivery succeeded; contains error details on failure.
+ public string? ErrorMessage { get; init; }
+
+ public bool IsSuccess => ErrorMessage is null;
+}
diff --git a/HrynCo.NotificationService.Contracts/Messages/NotificationResultMessage.cs b/HrynCo.NotificationService.Contracts/Messages/NotificationResultMessage.cs
new file mode 100644
index 0000000..82bc01f
--- /dev/null
+++ b/HrynCo.NotificationService.Contracts/Messages/NotificationResultMessage.cs
@@ -0,0 +1,9 @@
+namespace HrynCo.NotificationService.Contracts.Messages;
+
+using Hrynco.RabbitMq;
+
+public record NotificationResultMessage : IRabbitMqMessage
+{
+ public CorrelationContext CorrelationContext { get; set; } = null!;
+ public NotificationResultData Data { get; set; } = null!;
+}
diff --git a/HrynCo.NotificationService.Contracts/Messages/SendEmailMessage.cs b/HrynCo.NotificationService.Contracts/Messages/SendEmailMessage.cs
new file mode 100644
index 0000000..f29e101
--- /dev/null
+++ b/HrynCo.NotificationService.Contracts/Messages/SendEmailMessage.cs
@@ -0,0 +1,9 @@
+namespace HrynCo.NotificationService.Contracts.Messages;
+
+using Hrynco.RabbitMq;
+
+public record SendEmailMessage : IRabbitMqMessage
+{
+ public CorrelationContext CorrelationContext { get; set; } = default!;
+ public SendEmailMessageData Data { get; set; } = default!;
+}
diff --git a/HrynCo.NotificationService.Contracts/Messages/SendEmailMessageData.cs b/HrynCo.NotificationService.Contracts/Messages/SendEmailMessageData.cs
new file mode 100644
index 0000000..ad5fddb
--- /dev/null
+++ b/HrynCo.NotificationService.Contracts/Messages/SendEmailMessageData.cs
@@ -0,0 +1,11 @@
+namespace HrynCo.NotificationService.Contracts.Messages;
+
+public record SendEmailMessageData
+{
+ public required string ServiceName { get; init; }
+ public required string TemplateKey { get; init; }
+ public required string RecipientEmail { get; init; }
+ public required string RecipientName { get; init; }
+ public required IReadOnlyDictionary Variables { get; init; }
+ public string? LanguageCode { get; init; }
+}
diff --git a/HrynCo.NotificationService.DAL.Abstract/Entities/Entity.cs b/HrynCo.NotificationService.DAL.Abstract/Entities/Entity.cs
new file mode 100644
index 0000000..fcec809
--- /dev/null
+++ b/HrynCo.NotificationService.DAL.Abstract/Entities/Entity.cs
@@ -0,0 +1,17 @@
+namespace HrynCo.NotificationService.DAL.Abstract.Entities;
+
+public abstract class Entity : IEntity where TId : struct
+{
+ public TId Id { get; set; }
+ public DateTimeOffset Created { get; set; }
+ public DateTimeOffset? Updated { get; set; }
+}
+
+public abstract class Entity : Entity
+{
+ protected Entity()
+ {
+ Id = Guid.NewGuid();
+ Created = DateTimeOffset.UtcNow;
+ }
+}
diff --git a/HrynCo.NotificationService.DAL.Abstract/Entities/IEntity.cs b/HrynCo.NotificationService.DAL.Abstract/Entities/IEntity.cs
new file mode 100644
index 0000000..a1445ab
--- /dev/null
+++ b/HrynCo.NotificationService.DAL.Abstract/Entities/IEntity.cs
@@ -0,0 +1,8 @@
+namespace HrynCo.NotificationService.DAL.Abstract.Entities;
+
+public interface IEntity where TId : struct
+{
+ TId Id { get; set; }
+ DateTimeOffset Created { get; set; }
+ DateTimeOffset? Updated { get; set; }
+}
diff --git a/HrynCo.NotificationService.DAL.Abstract/HrynCo.NotificationService.DAL.Abstract.csproj b/HrynCo.NotificationService.DAL.Abstract/HrynCo.NotificationService.DAL.Abstract.csproj
new file mode 100644
index 0000000..b760144
--- /dev/null
+++ b/HrynCo.NotificationService.DAL.Abstract/HrynCo.NotificationService.DAL.Abstract.csproj
@@ -0,0 +1,9 @@
+
+
+
+ net10.0
+ enable
+ enable
+
+
+
diff --git a/HrynCo.NotificationService.DAL.Abstract/ITransaction.cs b/HrynCo.NotificationService.DAL.Abstract/ITransaction.cs
new file mode 100644
index 0000000..9e75639
--- /dev/null
+++ b/HrynCo.NotificationService.DAL.Abstract/ITransaction.cs
@@ -0,0 +1,7 @@
+namespace HrynCo.NotificationService.DAL.Abstract;
+
+public interface ITransaction : IAsyncDisposable
+{
+ Task CommitAsync(CancellationToken cancellationToken = default);
+ Task RollbackAsync(CancellationToken cancellationToken = default);
+}
diff --git a/HrynCo.NotificationService.DAL.Abstract/IUnitOfWork.cs b/HrynCo.NotificationService.DAL.Abstract/IUnitOfWork.cs
new file mode 100644
index 0000000..3e1bf64
--- /dev/null
+++ b/HrynCo.NotificationService.DAL.Abstract/IUnitOfWork.cs
@@ -0,0 +1,11 @@
+namespace HrynCo.NotificationService.DAL.Abstract;
+
+public interface IUnitOfWork
+{
+ Task SaveChangesAsync(CancellationToken cancellationToken = default);
+ Task BeginTransactionAsync(CancellationToken cancellationToken = default);
+ ITransaction? GetCurrentTransaction();
+
+ Task ExecuteInTransactionAsync(Func action);
+ Task ExecuteInTransactionAsync(Func> action);
+}
diff --git a/HrynCo.NotificationService.DAL.Abstract/Providers/ChannelWithUsage.cs b/HrynCo.NotificationService.DAL.Abstract/Providers/ChannelWithUsage.cs
new file mode 100644
index 0000000..062e86f
--- /dev/null
+++ b/HrynCo.NotificationService.DAL.Abstract/Providers/ChannelWithUsage.cs
@@ -0,0 +1,6 @@
+namespace HrynCo.NotificationService.DAL.Abstract.Providers;
+
+public record ChannelWithUsage(
+ EmailChannel Channel,
+ int DailySent,
+ int MonthlySent);
diff --git a/HrynCo.NotificationService.DAL.Abstract/Providers/EmailChannel.cs b/HrynCo.NotificationService.DAL.Abstract/Providers/EmailChannel.cs
new file mode 100644
index 0000000..b288734
--- /dev/null
+++ b/HrynCo.NotificationService.DAL.Abstract/Providers/EmailChannel.cs
@@ -0,0 +1,15 @@
+using HrynCo.NotificationService.DAL.Abstract.Entities;
+
+namespace HrynCo.NotificationService.DAL.Abstract.Providers;
+
+public class EmailChannel : Entity
+{
+ public required string ServiceName { get; set; }
+ public int Priority { get; set; }
+ public EmailChannelType EmailChannelType { get; set; }
+ public required EmailChannelSettings Settings { get; set; }
+ public int? DailyLimit { get; set; }
+ 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/EmailChannelSettings.cs b/HrynCo.NotificationService.DAL.Abstract/Providers/EmailChannelSettings.cs
new file mode 100644
index 0000000..24cf23c
--- /dev/null
+++ b/HrynCo.NotificationService.DAL.Abstract/Providers/EmailChannelSettings.cs
@@ -0,0 +1,19 @@
+namespace HrynCo.NotificationService.DAL.Abstract.Providers;
+
+public abstract class EmailChannelSettings
+{
+ public abstract EmailChannelType EmailChannelType { get; }
+}
+
+public class SmtpChannelSettings : EmailChannelSettings
+{
+ public override EmailChannelType EmailChannelType => EmailChannelType.Smtp;
+
+ public string Host { get; set; } = string.Empty;
+ public int Port { get; set; } = 587;
+ public string Username { get; set; } = string.Empty;
+ public string Password { get; set; } = string.Empty;
+ public bool UseSsl { get; set; } = true;
+ public string FromEmail { get; set; } = string.Empty;
+ public string FromName { get; set; } = string.Empty;
+}
diff --git a/HrynCo.NotificationService.DAL.Abstract/Providers/EmailChannelType.cs b/HrynCo.NotificationService.DAL.Abstract/Providers/EmailChannelType.cs
new file mode 100644
index 0000000..cf33f8b
--- /dev/null
+++ b/HrynCo.NotificationService.DAL.Abstract/Providers/EmailChannelType.cs
@@ -0,0 +1,7 @@
+namespace HrynCo.NotificationService.DAL.Abstract.Providers;
+
+public enum EmailChannelType
+{
+ Undefined = 0,
+ Smtp = 1
+}
diff --git a/HrynCo.NotificationService.DAL.Abstract/Providers/EmailChannelUsage.cs b/HrynCo.NotificationService.DAL.Abstract/Providers/EmailChannelUsage.cs
new file mode 100644
index 0000000..336f284
--- /dev/null
+++ b/HrynCo.NotificationService.DAL.Abstract/Providers/EmailChannelUsage.cs
@@ -0,0 +1,14 @@
+using HrynCo.NotificationService.DAL.Abstract.Entities;
+
+namespace HrynCo.NotificationService.DAL.Abstract.Providers;
+
+///
+/// Tracks email send counts per EmailChannel per calendar day.
+/// Monthly counts are derived by summing daily records within a month.
+///
+public class EmailChannelUsage : Entity
+{
+ public Guid ProviderId { get; set; }
+ public DateOnly Date { get; set; }
+ public int SentCount { get; set; }
+}
diff --git a/HrynCo.NotificationService.DAL.Abstract/Repositories/IEmailChannelRepository.cs b/HrynCo.NotificationService.DAL.Abstract/Repositories/IEmailChannelRepository.cs
new file mode 100644
index 0000000..a0647d6
--- /dev/null
+++ b/HrynCo.NotificationService.DAL.Abstract/Repositories/IEmailChannelRepository.cs
@@ -0,0 +1,14 @@
+using HrynCo.NotificationService.DAL.Abstract.Providers;
+
+namespace HrynCo.NotificationService.DAL.Abstract.Repositories;
+
+public interface IEmailChannelRepository
+{
+ Task> GetAllAsync(CancellationToken ct = default);
+ Task> GetByServiceAsync(string serviceName, CancellationToken ct = default);
+ Task GetByIdAsync(Guid id, CancellationToken ct = default);
+ Task> GetAllWithUsageSummaryAsync(DateOnly today, CancellationToken ct = default);
+ Task AddAsync(EmailChannel channel, CancellationToken ct = default);
+ Task UpdateAsync(EmailChannel channel, CancellationToken ct = default);
+ Task DeleteAsync(EmailChannel channel, CancellationToken ct = default);
+}
diff --git a/HrynCo.NotificationService.DAL.Abstract/Repositories/IEmailChannelUsageRepository.cs b/HrynCo.NotificationService.DAL.Abstract/Repositories/IEmailChannelUsageRepository.cs
new file mode 100644
index 0000000..809ec88
--- /dev/null
+++ b/HrynCo.NotificationService.DAL.Abstract/Repositories/IEmailChannelUsageRepository.cs
@@ -0,0 +1,8 @@
+namespace HrynCo.NotificationService.DAL.Abstract.Repositories;
+
+public interface IEmailChannelUsageRepository
+{
+ Task GetDailyCountAsync(Guid providerId, DateOnly date, CancellationToken ct = default);
+ Task GetMonthlyCountAsync(Guid providerId, int year, int month, CancellationToken ct = default);
+ Task IncrementUsageAsync(Guid providerId, DateOnly date, CancellationToken ct = default);
+}
diff --git a/HrynCo.NotificationService.DAL.Abstract/Repositories/IEmailTemplateRepository.cs b/HrynCo.NotificationService.DAL.Abstract/Repositories/IEmailTemplateRepository.cs
new file mode 100644
index 0000000..b2cac8b
--- /dev/null
+++ b/HrynCo.NotificationService.DAL.Abstract/Repositories/IEmailTemplateRepository.cs
@@ -0,0 +1,13 @@
+using HrynCo.NotificationService.DAL.Abstract.Templates;
+
+namespace HrynCo.NotificationService.DAL.Abstract.Repositories;
+
+public interface IEmailTemplateRepository
+{
+ Task> GetAllAsync(CancellationToken ct = default);
+ Task> GetByServiceAsync(string serviceName, CancellationToken ct = default);
+ Task GetAsync(string serviceName, string key, string languageCode, CancellationToken ct = default);
+ Task AddAsync(EmailTemplate template, CancellationToken ct = default);
+ Task UpdateAsync(EmailTemplate template, CancellationToken ct = default);
+ Task DeleteAsync(EmailTemplate template, CancellationToken ct = default);
+}
\ No newline at end of file
diff --git a/HrynCo.NotificationService.DAL.Abstract/Templates/EmailTemplate.cs b/HrynCo.NotificationService.DAL.Abstract/Templates/EmailTemplate.cs
new file mode 100644
index 0000000..d436382
--- /dev/null
+++ b/HrynCo.NotificationService.DAL.Abstract/Templates/EmailTemplate.cs
@@ -0,0 +1,14 @@
+using HrynCo.NotificationService.DAL.Abstract.Entities;
+
+namespace HrynCo.NotificationService.DAL.Abstract.Templates;
+
+public class EmailTemplate : Entity
+{
+ public required string ServiceName { get; set; }
+ public required string Key { get; set; }
+ public required string LanguageCode { get; set; }
+ public required string Subject { get; set; }
+ public required string HtmlBody { get; set; }
+ public required string TextBody { get; set; }
+ public IReadOnlyList Variables { get; set; } = [];
+}
diff --git a/HrynCo.NotificationService.DAL.Abstract/Templates/TemplateVariable.cs b/HrynCo.NotificationService.DAL.Abstract/Templates/TemplateVariable.cs
new file mode 100644
index 0000000..3180ad5
--- /dev/null
+++ b/HrynCo.NotificationService.DAL.Abstract/Templates/TemplateVariable.cs
@@ -0,0 +1,7 @@
+namespace HrynCo.NotificationService.DAL.Abstract.Templates;
+
+public record EmailTemplateVariable
+{
+ public required string Name { get; init; }
+ public bool Required { get; init; }
+}
diff --git a/HrynCo.NotificationService.DAL.EF/Configurations/EmailChannelEntityConfiguration.cs b/HrynCo.NotificationService.DAL.EF/Configurations/EmailChannelEntityConfiguration.cs
new file mode 100644
index 0000000..8b560f6
--- /dev/null
+++ b/HrynCo.NotificationService.DAL.EF/Configurations/EmailChannelEntityConfiguration.cs
@@ -0,0 +1,43 @@
+using HrynCo.NotificationService.DAL.EF.Entities;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+
+namespace HrynCo.NotificationService.DAL.EF.Configurations;
+
+internal class EmailChannelEntityConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ToTable("email_channels");
+
+ builder.HasKey(x => x.Id);
+ builder.Property(x => x.Id).HasColumnName("id");
+
+ builder.Property(x => x.ServiceName)
+ .HasColumnName("service_name")
+ .IsRequired()
+ .HasMaxLength(100);
+
+ builder.HasIndex(x => new { x.ServiceName, x.Priority });
+
+ builder.Property(x => x.Priority).HasColumnName("priority");
+
+ builder.Property(x => x.EmailChannelType).HasColumnName("provider_type");
+
+ builder.Property(x => x.SettingsJson)
+ .HasColumnName("settings")
+ .HasColumnType("jsonb")
+ .IsRequired();
+
+ builder.Property(x => x.DailyLimit).HasColumnName("daily_limit");
+ builder.Property(x => x.MonthlyLimit).HasColumnName("monthly_limit");
+ builder.Property(x => x.WarnThresholdPercent).HasColumnName("warn_threshold_percent");
+ builder.Property(x => x.IsActive).HasColumnName("is_active");
+ builder.Property(x => x.Created).HasColumnName("created");
+ builder.Property(x => x.Updated).HasColumnName("updated");
+
+ builder.HasMany(x => x.UsageRecords)
+ .WithOne()
+ .HasForeignKey(u => u.ProviderId);
+ }
+}
diff --git a/HrynCo.NotificationService.DAL.EF/Configurations/EmailChannelUsageEntityConfiguration.cs b/HrynCo.NotificationService.DAL.EF/Configurations/EmailChannelUsageEntityConfiguration.cs
new file mode 100644
index 0000000..576fb7e
--- /dev/null
+++ b/HrynCo.NotificationService.DAL.EF/Configurations/EmailChannelUsageEntityConfiguration.cs
@@ -0,0 +1,25 @@
+using HrynCo.NotificationService.DAL.EF.Entities;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+
+namespace HrynCo.NotificationService.DAL.EF.Configurations;
+
+internal class EmailChannelUsageEntityConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ToTable("email_channel_usage");
+
+ builder.HasKey(x => x.Id);
+ builder.Property(x => x.Id).HasColumnName("id");
+
+ builder.Property(x => x.ProviderId).HasColumnName("provider_id");
+
+ builder.HasIndex(x => new { x.ProviderId, x.Date }).IsUnique();
+
+ builder.Property(x => x.Date).HasColumnName("date");
+ builder.Property(x => x.SentCount).HasColumnName("sent_count");
+ builder.Property(x => x.Created).HasColumnName("created");
+ builder.Property(x => x.Updated).HasColumnName("updated");
+ }
+}
diff --git a/HrynCo.NotificationService.DAL.EF/Configurations/EmailTemplateEntityConfiguration.cs b/HrynCo.NotificationService.DAL.EF/Configurations/EmailTemplateEntityConfiguration.cs
new file mode 100644
index 0000000..7522d01
--- /dev/null
+++ b/HrynCo.NotificationService.DAL.EF/Configurations/EmailTemplateEntityConfiguration.cs
@@ -0,0 +1,56 @@
+using HrynCo.NotificationService.DAL.EF.Entities;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+
+namespace HrynCo.NotificationService.DAL.EF.Configurations;
+
+internal class EmailTemplateEntityConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ToTable("email_templates");
+
+ builder.HasKey(x => x.Id);
+ builder.Property(x => x.Id).HasColumnName("id");
+
+ builder.Property(x => x.ServiceName)
+ .HasColumnName("service_name")
+ .IsRequired()
+ .HasMaxLength(100);
+
+ builder.Property(x => x.Key)
+ .HasColumnName("key")
+ .IsRequired()
+ .HasMaxLength(100);
+
+ builder.Property(x => x.LanguageCode)
+ .HasColumnName("language_code")
+ .IsRequired()
+ .HasMaxLength(10);
+
+ builder.HasIndex(x => new { x.ServiceName, x.Key, x.LanguageCode })
+ .IsUnique();
+
+ builder.Property(x => x.Subject)
+ .HasColumnName("subject")
+ .IsRequired();
+
+ builder.Property(x => x.HtmlBody)
+ .HasColumnName("html_body")
+ .IsRequired();
+
+ builder.Property(x => x.TextBody)
+ .HasColumnName("text_body")
+ .IsRequired();
+
+ builder.Property(x => x.Created).HasColumnName("created");
+ builder.Property(x => x.Updated).HasColumnName("updated");
+
+ builder.OwnsMany(x => x.Variables, v =>
+ {
+ v.ToJson("variables");
+ v.Property(x => x.Name).HasJsonPropertyName("name");
+ v.Property(x => x.Required).HasJsonPropertyName("required");
+ });
+ }
+}
diff --git a/HrynCo.NotificationService.DAL.EF/Core/EfRepository.cs b/HrynCo.NotificationService.DAL.EF/Core/EfRepository.cs
new file mode 100644
index 0000000..2cd5661
--- /dev/null
+++ b/HrynCo.NotificationService.DAL.EF/Core/EfRepository.cs
@@ -0,0 +1,45 @@
+using System.Linq.Expressions;
+using Microsoft.EntityFrameworkCore;
+
+namespace HrynCo.NotificationService.DAL.EF.Core;
+
+internal abstract class EfRepository
+ where TEntity : class
+{
+ protected NotificationDbContext DbContext { get; }
+ protected DbSet DbSet { get; }
+
+ protected EfRepository(NotificationDbContext dbContext)
+ {
+ DbContext = dbContext;
+ DbSet = dbContext.Set();
+ }
+
+ protected async Task AddAsync(TEntity entity, CancellationToken ct = default)
+ {
+ await DbSet.AddAsync(entity, ct);
+ }
+
+ protected async Task AddRangeAsync(IEnumerable entities, CancellationToken ct = default)
+ {
+ await DbSet.AddRangeAsync(entities, ct);
+ }
+
+ protected void Update(TEntity entity)
+ {
+ DbSet.Update(entity);
+ }
+
+ protected void Delete(TEntity entity)
+ {
+ DbSet.Remove(entity);
+ }
+
+ protected void DeleteRange(IEnumerable entities)
+ {
+ DbSet.RemoveRange(entities);
+ }
+
+ protected Task ExistsAsync(Expression> predicate, CancellationToken ct = default) =>
+ DbSet.AnyAsync(predicate, ct);
+}
\ No newline at end of file
diff --git a/HrynCo.NotificationService.DAL.EF/Core/EfTransactionAdapter.cs b/HrynCo.NotificationService.DAL.EF/Core/EfTransactionAdapter.cs
new file mode 100644
index 0000000..e5855af
--- /dev/null
+++ b/HrynCo.NotificationService.DAL.EF/Core/EfTransactionAdapter.cs
@@ -0,0 +1,29 @@
+using HrynCo.NotificationService.DAL.Abstract;
+using Microsoft.EntityFrameworkCore.Storage;
+
+namespace HrynCo.NotificationService.DAL.EF.Core;
+
+internal sealed class EfTransactionAdapter : ITransaction
+{
+ private readonly IDbContextTransaction _transaction;
+
+ internal EfTransactionAdapter(IDbContextTransaction transaction)
+ {
+ _transaction = transaction;
+ }
+
+ public Task CommitAsync(CancellationToken cancellationToken = default)
+ {
+ return _transaction.CommitAsync(cancellationToken);
+ }
+
+ public Task RollbackAsync(CancellationToken cancellationToken = default)
+ {
+ return _transaction.RollbackAsync(cancellationToken);
+ }
+
+ public ValueTask DisposeAsync()
+ {
+ return _transaction.DisposeAsync();
+ }
+}
\ No newline at end of file
diff --git a/HrynCo.NotificationService.DAL.EF/Core/EfUnitOfWork.cs b/HrynCo.NotificationService.DAL.EF/Core/EfUnitOfWork.cs
new file mode 100644
index 0000000..0d45441
--- /dev/null
+++ b/HrynCo.NotificationService.DAL.EF/Core/EfUnitOfWork.cs
@@ -0,0 +1,105 @@
+using HrynCo.NotificationService.DAL.Abstract;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Storage;
+
+namespace HrynCo.NotificationService.DAL.EF.Core;
+
+internal abstract class EfUnitOfWork : IUnitOfWork
+ where TDbContext : DbContext
+{
+ private readonly TDbContext _context;
+ private EfTransactionAdapter? _currentTransaction;
+
+ protected EfUnitOfWork(TDbContext context)
+ {
+ _context = context;
+ }
+
+ public Task SaveChangesAsync(CancellationToken cancellationToken = default)
+ {
+ return _context.SaveChangesAsync(cancellationToken);
+ }
+
+ public async Task BeginTransactionAsync(CancellationToken cancellationToken = default)
+ {
+ if (_currentTransaction != null)
+ {
+ return _currentTransaction;
+ }
+
+ IDbContextTransaction tx = await _context.Database.BeginTransactionAsync(cancellationToken);
+ _currentTransaction = new EfTransactionAdapter(tx);
+ return _currentTransaction;
+ }
+
+ public ITransaction? GetCurrentTransaction()
+ {
+ return _currentTransaction;
+ }
+
+ public async Task ExecuteInTransactionAsync(Func action)
+ {
+ ITransaction? existing = GetCurrentTransaction();
+ bool ownsTransaction = existing is null;
+ ITransaction tx = existing ?? await BeginTransactionAsync();
+
+ try
+ {
+ await action();
+ if (ownsTransaction)
+ {
+ await tx.CommitAsync();
+ }
+ }
+ catch
+ {
+ if (ownsTransaction)
+ {
+ await tx.RollbackAsync();
+ }
+
+ throw;
+ }
+ finally
+ {
+ if (ownsTransaction)
+ {
+ await tx.DisposeAsync();
+ }
+ }
+ }
+
+ public async Task ExecuteInTransactionAsync(Func> action)
+ {
+ ITransaction? existing = GetCurrentTransaction();
+ bool ownsTransaction = existing is null;
+ ITransaction tx = existing ?? await BeginTransactionAsync();
+
+ try
+ {
+ TResult result = await action();
+ if (ownsTransaction)
+ {
+ await tx.CommitAsync();
+ }
+
+ return result;
+ }
+ catch
+ {
+ if (ownsTransaction)
+ {
+ await tx.RollbackAsync();
+ }
+
+ throw;
+ }
+ finally
+ {
+ if (ownsTransaction)
+ {
+ await tx.DisposeAsync();
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/HrynCo.NotificationService.DAL.EF/Core/UnitOfWork.cs b/HrynCo.NotificationService.DAL.EF/Core/UnitOfWork.cs
new file mode 100644
index 0000000..7e8ebfa
--- /dev/null
+++ b/HrynCo.NotificationService.DAL.EF/Core/UnitOfWork.cs
@@ -0,0 +1,8 @@
+namespace HrynCo.NotificationService.DAL.EF.Core;
+
+internal sealed class UnitOfWork : EfUnitOfWork
+{
+ public UnitOfWork(NotificationDbContext context) : base(context)
+ {
+ }
+}
diff --git a/HrynCo.NotificationService.DAL.EF/Entities/EmailChannelEntity.cs b/HrynCo.NotificationService.DAL.EF/Entities/EmailChannelEntity.cs
new file mode 100644
index 0000000..4db02b4
--- /dev/null
+++ b/HrynCo.NotificationService.DAL.EF/Entities/EmailChannelEntity.cs
@@ -0,0 +1,24 @@
+using HrynCo.NotificationService.DAL.Abstract.Entities;
+using HrynCo.NotificationService.DAL.Abstract.Providers;
+
+namespace HrynCo.NotificationService.DAL.EF.Entities;
+
+internal class EmailChannelEntity : Entity
+{
+ public required string ServiceName { get; set; }
+ public int Priority { get; set; }
+ public EmailChannelType EmailChannelType { get; set; }
+
+ ///
+ /// EmailChannel-specific credentials and settings stored as JSONB.
+ /// Deserialized based on in the repository.
+ ///
+ public required string SettingsJson { get; set; }
+
+ public int? DailyLimit { get; set; }
+ public int? MonthlyLimit { get; set; }
+ public int WarnThresholdPercent { get; set; }
+ public bool IsActive { get; set; }
+
+ public ICollection UsageRecords { get; set; } = [];
+}
diff --git a/HrynCo.NotificationService.DAL.EF/Entities/EmailChannelUsageEntity.cs b/HrynCo.NotificationService.DAL.EF/Entities/EmailChannelUsageEntity.cs
new file mode 100644
index 0000000..71fe531
--- /dev/null
+++ b/HrynCo.NotificationService.DAL.EF/Entities/EmailChannelUsageEntity.cs
@@ -0,0 +1,10 @@
+using HrynCo.NotificationService.DAL.Abstract.Entities;
+
+namespace HrynCo.NotificationService.DAL.EF.Entities;
+
+internal class EmailChannelUsageEntity : Entity
+{
+ public Guid ProviderId { get; set; }
+ public DateOnly Date { get; set; }
+ public int SentCount { get; set; }
+}
diff --git a/HrynCo.NotificationService.DAL.EF/Entities/EmailTemplateEntity.cs b/HrynCo.NotificationService.DAL.EF/Entities/EmailTemplateEntity.cs
new file mode 100644
index 0000000..f22f3d6
--- /dev/null
+++ b/HrynCo.NotificationService.DAL.EF/Entities/EmailTemplateEntity.cs
@@ -0,0 +1,20 @@
+using HrynCo.NotificationService.DAL.Abstract.Entities;
+
+namespace HrynCo.NotificationService.DAL.EF.Entities;
+
+internal class EmailTemplateEntity : Entity
+{
+ public required string ServiceName { get; set; }
+ public required string Key { get; set; }
+ public required string LanguageCode { get; set; }
+ public required string Subject { get; set; }
+ public required string HtmlBody { get; set; }
+ public required string TextBody { get; set; }
+ public List Variables { get; set; } = [];
+}
+
+internal class EmailTemplateVariableData
+{
+ public required string Name { get; set; }
+ public bool Required { get; set; }
+}
diff --git a/HrynCo.NotificationService.DAL.EF/HrynCo.NotificationService.DAL.EF.csproj b/HrynCo.NotificationService.DAL.EF/HrynCo.NotificationService.DAL.EF.csproj
new file mode 100644
index 0000000..f073f56
--- /dev/null
+++ b/HrynCo.NotificationService.DAL.EF/HrynCo.NotificationService.DAL.EF.csproj
@@ -0,0 +1,23 @@
+
+
+
+
+
+
+
+
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
+
+
+
+
+
+ net10.0
+ enable
+ enable
+
+
+
diff --git a/HrynCo.NotificationService.DAL.EF/Migrations/20260501214629_InitialCreate.Designer.cs b/HrynCo.NotificationService.DAL.EF/Migrations/20260501214629_InitialCreate.Designer.cs
new file mode 100644
index 0000000..8d3fbe1
--- /dev/null
+++ b/HrynCo.NotificationService.DAL.EF/Migrations/20260501214629_InitialCreate.Designer.cs
@@ -0,0 +1,211 @@
+//
+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("20260501214629_InitialCreate")]
+ partial class InitialCreate
+ {
+ ///
+ 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.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");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/HrynCo.NotificationService.DAL.EF/Migrations/20260501214629_InitialCreate.cs b/HrynCo.NotificationService.DAL.EF/Migrations/20260501214629_InitialCreate.cs
new file mode 100644
index 0000000..8618cf7
--- /dev/null
+++ b/HrynCo.NotificationService.DAL.EF/Migrations/20260501214629_InitialCreate.cs
@@ -0,0 +1,102 @@
+using System;
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace HrynCo.NotificationService.DAL.EF.Migrations
+{
+ ///
+ public partial class InitialCreate : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.CreateTable(
+ name: "email_channel_usage",
+ columns: table => new
+ {
+ id = table.Column(type: "uuid", nullable: false),
+ provider_id = table.Column(type: "uuid", nullable: false),
+ date = table.Column(type: "date", nullable: false),
+ sent_count = table.Column(type: "integer", nullable: false),
+ created = table.Column(type: "timestamp with time zone", nullable: false),
+ updated = table.Column(type: "timestamp with time zone", nullable: true)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_email_channel_usage", x => x.id);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "email_channels",
+ columns: table => new
+ {
+ id = table.Column(type: "uuid", nullable: false),
+ service_name = table.Column(type: "character varying(100)", maxLength: 100, nullable: false),
+ priority = table.Column(type: "integer", nullable: false),
+ provider_type = table.Column(type: "integer", nullable: false),
+ settings = table.Column(type: "jsonb", nullable: false),
+ daily_limit = table.Column(type: "integer", nullable: true),
+ monthly_limit = table.Column(type: "integer", nullable: true),
+ warn_threshold_percent = table.Column(type: "integer", nullable: false),
+ is_active = table.Column(type: "boolean", nullable: false),
+ created = table.Column(type: "timestamp with time zone", nullable: false),
+ updated = table.Column(type: "timestamp with time zone", nullable: true)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_email_channels", x => x.id);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "email_templates",
+ columns: table => new
+ {
+ id = table.Column(type: "uuid", nullable: false),
+ service_name = table.Column(type: "character varying(100)", maxLength: 100, nullable: false),
+ key = table.Column(type: "character varying(100)", maxLength: 100, nullable: false),
+ language_code = table.Column(type: "character varying(10)", maxLength: 10, nullable: false),
+ subject = table.Column(type: "text", nullable: false),
+ html_body = table.Column(type: "text", nullable: false),
+ text_body = table.Column(type: "text", nullable: false),
+ created = table.Column(type: "timestamp with time zone", nullable: false),
+ updated = table.Column(type: "timestamp with time zone", nullable: true),
+ variables = table.Column(type: "jsonb", nullable: true)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_email_templates", x => x.id);
+ });
+
+ migrationBuilder.CreateIndex(
+ name: "IX_email_channel_usage_provider_id_date",
+ table: "email_channel_usage",
+ columns: new[] { "provider_id", "date" },
+ unique: true);
+
+ migrationBuilder.CreateIndex(
+ name: "IX_email_channels_service_name_priority",
+ table: "email_channels",
+ columns: new[] { "service_name", "priority" });
+
+ migrationBuilder.CreateIndex(
+ name: "IX_email_templates_service_name_key_language_code",
+ table: "email_templates",
+ columns: new[] { "service_name", "key", "language_code" },
+ unique: true);
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropTable(
+ name: "email_channel_usage");
+
+ migrationBuilder.DropTable(
+ name: "email_channels");
+
+ migrationBuilder.DropTable(
+ name: "email_templates");
+ }
+ }
+}
diff --git a/HrynCo.NotificationService.DAL.EF/Migrations/NotificationDbContextModelSnapshot.cs b/HrynCo.NotificationService.DAL.EF/Migrations/NotificationDbContextModelSnapshot.cs
new file mode 100644
index 0000000..4266c16
--- /dev/null
+++ b/HrynCo.NotificationService.DAL.EF/Migrations/NotificationDbContextModelSnapshot.cs
@@ -0,0 +1,208 @@
+//
+using System;
+using HrynCo.NotificationService.DAL.EF;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+
+#nullable disable
+
+namespace HrynCo.NotificationService.DAL.EF.Migrations
+{
+ [DbContext(typeof(NotificationDbContext))]
+ partial class NotificationDbContextModelSnapshot : ModelSnapshot
+ {
+ protected override void BuildModel(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.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");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/HrynCo.NotificationService.DAL.EF/NotificationDbContext.cs b/HrynCo.NotificationService.DAL.EF/NotificationDbContext.cs
new file mode 100644
index 0000000..bcf61e3
--- /dev/null
+++ b/HrynCo.NotificationService.DAL.EF/NotificationDbContext.cs
@@ -0,0 +1,21 @@
+using HrynCo.NotificationService.DAL.EF.Entities;
+using Microsoft.EntityFrameworkCore;
+
+namespace HrynCo.NotificationService.DAL.EF;
+
+public class NotificationDbContext : DbContext
+{
+ public NotificationDbContext(DbContextOptions options)
+ : base(options)
+ {
+ }
+
+ internal DbSet Templates => Set();
+ internal DbSet Providers => Set();
+ internal DbSet EmailChannelUsage => Set();
+
+ protected override void OnModelCreating(ModelBuilder modelBuilder)
+ {
+ modelBuilder.ApplyConfigurationsFromAssembly(typeof(NotificationDbContext).Assembly);
+ }
+}
diff --git a/HrynCo.NotificationService.DAL.EF/NotificationDbContextFactory.cs b/HrynCo.NotificationService.DAL.EF/NotificationDbContextFactory.cs
new file mode 100644
index 0000000..57a7f5e
--- /dev/null
+++ b/HrynCo.NotificationService.DAL.EF/NotificationDbContextFactory.cs
@@ -0,0 +1,16 @@
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Design;
+
+namespace HrynCo.NotificationService.DAL.EF;
+
+internal sealed class NotificationDbContextFactory : IDesignTimeDbContextFactory
+{
+ public NotificationDbContext CreateDbContext(string[] args)
+ {
+ var options = new DbContextOptionsBuilder()
+ .UseNpgsql("Host=localhost;Port=5432;Database=notification_service;Username=postgres;Password=postgres")
+ .Options;
+
+ return new NotificationDbContext(options);
+ }
+}
\ No newline at end of file
diff --git a/HrynCo.NotificationService.DAL.EF/Repositories/EmailChannelRepository.cs b/HrynCo.NotificationService.DAL.EF/Repositories/EmailChannelRepository.cs
new file mode 100644
index 0000000..81f2210
--- /dev/null
+++ b/HrynCo.NotificationService.DAL.EF/Repositories/EmailChannelRepository.cs
@@ -0,0 +1,136 @@
+using System.Text.Json;
+using HrynCo.NotificationService.DAL.Abstract.Providers;
+using HrynCo.NotificationService.DAL.Abstract.Repositories;
+using HrynCo.NotificationService.DAL.EF.Core;
+using HrynCo.NotificationService.DAL.EF.Entities;
+using Microsoft.EntityFrameworkCore;
+
+namespace HrynCo.NotificationService.DAL.EF.Repositories;
+
+internal sealed class EmailChannelRepository : EfRepository, IEmailChannelRepository
+{
+ public EmailChannelRepository(NotificationDbContext dbContext) : base(dbContext)
+ {
+ }
+
+ public async Task> GetAllAsync(CancellationToken ct = default)
+ {
+ var entities = await DbSet
+ .AsNoTracking()
+ .OrderBy(x => x.ServiceName)
+ .ThenBy(x => x.Priority)
+ .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)
+ .OrderBy(x => x.Priority)
+ .ToListAsync(ct);
+
+ return entities.Select(MapToDomain).ToList();
+ }
+
+ public async Task> GetAllWithUsageSummaryAsync(
+ DateOnly today, CancellationToken ct = default)
+ {
+ var rows = await DbSet
+ .AsNoTracking()
+ .OrderBy(c => c.ServiceName)
+ .ThenBy(c => c.Priority)
+ .Select(c => new
+ {
+ Channel = c,
+ DailySent = c.UsageRecords
+ .Where(u => u.Date == today)
+ .Sum(u => (int?)u.SentCount) ?? 0,
+ MonthlySent = c.UsageRecords
+ .Where(u => u.Date.Year == today.Year && u.Date.Month == today.Month)
+ .Sum(u => (int?)u.SentCount) ?? 0
+ })
+ .ToListAsync(ct);
+
+ return rows
+ .Select(r => new ChannelWithUsage(MapToDomain(r.Channel), r.DailySent, r.MonthlySent))
+ .ToList();
+ }
+
+ public async Task GetByIdAsync(Guid id, CancellationToken ct = default)
+ {
+ EmailChannelEntity? entity = await DbSet.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, ct);
+ return entity is null ? null : MapToDomain(entity);
+ }
+
+ public Task AddAsync(EmailChannel channel, CancellationToken ct = default)
+ {
+ return base.AddAsync(MapToEntity(channel), ct);
+ }
+
+ public Task UpdateAsync(EmailChannel channel, CancellationToken ct = default)
+ {
+ EmailChannelEntity entity = MapToEntity(channel);
+ entity.Updated = DateTimeOffset.UtcNow;
+ Update(entity);
+ return Task.CompletedTask;
+ }
+
+ public async Task DeleteAsync(EmailChannel channel, CancellationToken ct = default)
+ {
+ EmailChannelEntity? entity = await DbSet.FindAsync([channel.Id], ct);
+ if (entity is not null)
+ {
+ Delete(entity);
+ }
+ }
+
+ private static EmailChannel MapToDomain(EmailChannelEntity e)
+ {
+ return new EmailChannel
+ {
+ Id = e.Id,
+ ServiceName = e.ServiceName,
+ Priority = e.Priority,
+ EmailChannelType = e.EmailChannelType,
+ Settings = DeserializeSettings(e.EmailChannelType, e.SettingsJson),
+ DailyLimit = e.DailyLimit,
+ MonthlyLimit = e.MonthlyLimit,
+ WarnThresholdPercent = e.WarnThresholdPercent,
+ IsActive = e.IsActive,
+ Created = e.Created,
+ Updated = e.Updated
+ };
+ }
+
+ private static EmailChannelEntity MapToEntity(EmailChannel p)
+ {
+ return new EmailChannelEntity
+ {
+ Id = p.Id,
+ ServiceName = p.ServiceName,
+ Priority = p.Priority,
+ EmailChannelType = p.EmailChannelType,
+ SettingsJson = JsonSerializer.Serialize(p.Settings, p.Settings.GetType()),
+ DailyLimit = p.DailyLimit,
+ MonthlyLimit = p.MonthlyLimit,
+ WarnThresholdPercent = p.WarnThresholdPercent,
+ IsActive = p.IsActive,
+ Created = p.Created,
+ Updated = p.Updated
+ };
+ }
+
+ private static EmailChannelSettings DeserializeSettings(EmailChannelType type, string json)
+ {
+ return type switch
+ {
+ EmailChannelType.Smtp => JsonSerializer.Deserialize(json)
+ ?? throw new InvalidOperationException(
+ "Failed to deserialize SMTP EmailChannel settings."),
+ _ => throw new InvalidOperationException($"Unknown or undefined email channel type: {type}")
+ };
+ }
+}
\ No newline at end of file
diff --git a/HrynCo.NotificationService.DAL.EF/Repositories/EmailChannelUsageRepository.cs b/HrynCo.NotificationService.DAL.EF/Repositories/EmailChannelUsageRepository.cs
new file mode 100644
index 0000000..0a79344
--- /dev/null
+++ b/HrynCo.NotificationService.DAL.EF/Repositories/EmailChannelUsageRepository.cs
@@ -0,0 +1,44 @@
+using HrynCo.NotificationService.DAL.Abstract.Repositories;
+using HrynCo.NotificationService.DAL.EF.Core;
+using HrynCo.NotificationService.DAL.EF.Entities;
+using Microsoft.EntityFrameworkCore;
+
+namespace HrynCo.NotificationService.DAL.EF.Repositories;
+
+internal sealed class EmailChannelUsageRepository : EfRepository, IEmailChannelUsageRepository
+{
+ public EmailChannelUsageRepository(NotificationDbContext dbContext) : base(dbContext)
+ {
+ }
+
+ public async Task GetDailyCountAsync(Guid providerId, DateOnly date, CancellationToken ct = default)
+ {
+ EmailChannelUsageEntity? entity = await DbSet
+ .FirstOrDefaultAsync(x => x.ProviderId == providerId && x.Date == date, ct);
+
+ return entity?.SentCount ?? 0;
+ }
+
+ public async Task GetMonthlyCountAsync(Guid providerId, int year, int month, CancellationToken ct = default)
+ {
+ return await DbSet
+ .Where(x => x.ProviderId == providerId
+ && x.Date.Year == year
+ && x.Date.Month == month)
+ .SumAsync(x => x.SentCount, ct);
+ }
+
+ public async Task IncrementUsageAsync(Guid providerId, DateOnly date, CancellationToken ct = default)
+ {
+ EmailChannelUsageEntity? entity = await DbSet
+ .FirstOrDefaultAsync(x => x.ProviderId == providerId && x.Date == date, ct);
+
+ if (entity is null)
+ await AddAsync(new EmailChannelUsageEntity { ProviderId = providerId, Date = date, SentCount = 1 }, ct);
+ else
+ {
+ entity.SentCount++;
+ Update(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
new file mode 100644
index 0000000..e3960a8
--- /dev/null
+++ b/HrynCo.NotificationService.DAL.EF/Repositories/EmailTemplateRepository.cs
@@ -0,0 +1,83 @@
+using HrynCo.NotificationService.DAL.Abstract.Repositories;
+using HrynCo.NotificationService.DAL.Abstract.Templates;
+using HrynCo.NotificationService.DAL.EF.Core;
+using HrynCo.NotificationService.DAL.EF.Entities;
+using Microsoft.EntityFrameworkCore;
+
+namespace HrynCo.NotificationService.DAL.EF.Repositories;
+
+internal sealed class EmailTemplateRepository : EfRepository, IEmailTemplateRepository
+{
+ public EmailTemplateRepository(NotificationDbContext dbContext) : base(dbContext)
+ {
+ }
+
+ public async Task> GetAllAsync(CancellationToken ct = default)
+ {
+ List entities = await DbSet.ToListAsync(ct);
+ return entities.Select(MapToDomain).ToList();
+ }
+
+ public async Task> GetByServiceAsync(string serviceName, CancellationToken ct = default)
+ {
+ List entities = await DbSet
+ .Where(x => x.ServiceName == serviceName)
+ .ToListAsync(ct);
+
+ return entities.Select(MapToDomain).ToList();
+ }
+
+ public async Task GetAsync(string serviceName, string key, string languageCode, CancellationToken ct = default)
+ {
+ EmailTemplateEntity? entity = await DbSet.FirstOrDefaultAsync(
+ x => x.ServiceName == serviceName && x.Key == key && x.LanguageCode == languageCode, ct);
+
+ return entity is null ? null : MapToDomain(entity);
+ }
+
+ public Task AddAsync(EmailTemplate EmailTemplate, CancellationToken ct = default) =>
+ base.AddAsync(MapToEntity(EmailTemplate), ct);
+
+ public Task UpdateAsync(EmailTemplate EmailTemplate, CancellationToken ct = default)
+ {
+ EmailTemplateEntity entity = MapToEntity(EmailTemplate);
+ entity.Updated = DateTimeOffset.UtcNow;
+ Update(entity);
+ return Task.CompletedTask;
+ }
+
+ public async Task DeleteAsync(EmailTemplate EmailTemplate, CancellationToken ct = default)
+ {
+ EmailTemplateEntity? entity = await DbSet.FindAsync([EmailTemplate.Id], ct);
+ if (entity is not null)
+ Delete(entity);
+ }
+
+ private static EmailTemplate MapToDomain(EmailTemplateEntity e) => new()
+ {
+ Id = e.Id,
+ ServiceName = e.ServiceName,
+ Key = e.Key,
+ LanguageCode = e.LanguageCode,
+ Subject = e.Subject,
+ HtmlBody = e.HtmlBody,
+ TextBody = e.TextBody,
+ Variables = e.Variables.Select(v => new EmailTemplateVariable { Name = v.Name, Required = v.Required }).ToList(),
+ Created = e.Created,
+ Updated = e.Updated
+ };
+
+ private static EmailTemplateEntity MapToEntity(EmailTemplate t) => new()
+ {
+ Id = t.Id,
+ ServiceName = t.ServiceName,
+ Key = t.Key,
+ LanguageCode = t.LanguageCode,
+ Subject = t.Subject,
+ HtmlBody = t.HtmlBody,
+ TextBody = t.TextBody,
+ Variables = t.Variables.Select(v => new EmailTemplateVariableData { Name = v.Name, Required = v.Required }).ToList(),
+ Created = t.Created,
+ Updated = t.Updated
+ };
+}
\ No newline at end of file
diff --git a/HrynCo.NotificationService.DAL.EF/ServiceCollectionExtensions.cs b/HrynCo.NotificationService.DAL.EF/ServiceCollectionExtensions.cs
new file mode 100644
index 0000000..16edbb7
--- /dev/null
+++ b/HrynCo.NotificationService.DAL.EF/ServiceCollectionExtensions.cs
@@ -0,0 +1,26 @@
+using HrynCo.NotificationService.DAL.Abstract;
+using HrynCo.NotificationService.DAL.Abstract.Repositories;
+using HrynCo.NotificationService.DAL.EF.Core;
+using HrynCo.NotificationService.DAL.EF.Repositories;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace HrynCo.NotificationService.DAL.EF;
+
+public static class ServiceCollectionExtensions
+{
+ public static IServiceCollection AddNotificationDataAccess(
+ this IServiceCollection services,
+ string connectionString)
+ {
+ services.AddDbContext(options =>
+ options.UseNpgsql(connectionString));
+
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+
+ return services;
+ }
+}
diff --git a/HrynCo.NotificationService.Migrator/Dockerfile b/HrynCo.NotificationService.Migrator/Dockerfile
new file mode 100644
index 0000000..f34c4b4
--- /dev/null
+++ b/HrynCo.NotificationService.Migrator/Dockerfile
@@ -0,0 +1,17 @@
+FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
+WORKDIR /src
+
+COPY ["HrynCo.NotificationService.Migrator/HrynCo.NotificationService.Migrator.csproj", "HrynCo.NotificationService.Migrator/"]
+COPY ["HrynCo.NotificationService.DAL.EF/HrynCo.NotificationService.DAL.EF.csproj", "HrynCo.NotificationService.DAL.EF/"]
+COPY ["HrynCo.NotificationService.DAL.Abstract/HrynCo.NotificationService.DAL.Abstract.csproj", "HrynCo.NotificationService.DAL.Abstract/"]
+COPY ["Directory.Packages.props", "./"]
+COPY ["Directory.Build.props", "./"]
+RUN dotnet restore "HrynCo.NotificationService.Migrator/HrynCo.NotificationService.Migrator.csproj"
+
+COPY . .
+RUN dotnet publish "HrynCo.NotificationService.Migrator/HrynCo.NotificationService.Migrator.csproj" -c Release -o /app/publish --no-restore
+
+FROM mcr.microsoft.com/dotnet/runtime:10.0
+WORKDIR /app
+COPY --from=build /app/publish .
+ENTRYPOINT ["dotnet", "HrynCo.NotificationService.Migrator.dll"]
diff --git a/HrynCo.NotificationService.Migrator/HrynCo.NotificationService.Migrator.csproj b/HrynCo.NotificationService.Migrator/HrynCo.NotificationService.Migrator.csproj
new file mode 100644
index 0000000..080e9b3
--- /dev/null
+++ b/HrynCo.NotificationService.Migrator/HrynCo.NotificationService.Migrator.csproj
@@ -0,0 +1,25 @@
+
+
+
+ Exe
+ net10.0
+ enable
+ enable
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/HrynCo.NotificationService.Migrator/Program.cs b/HrynCo.NotificationService.Migrator/Program.cs
new file mode 100644
index 0000000..d3e979d
--- /dev/null
+++ b/HrynCo.NotificationService.Migrator/Program.cs
@@ -0,0 +1,46 @@
+using HrynCo.NotificationService.DAL.EF;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using Serilog;
+
+Log.Logger = new LoggerConfiguration()
+ .WriteTo.Console()
+ .CreateBootstrapLogger();
+
+try
+{
+ Log.Information("🚀 Notification Service Migrator starting...");
+
+ var host = Host.CreateDefaultBuilder(args)
+ .UseSerilog((ctx, cfg) => cfg
+ .ReadFrom.Configuration(ctx.Configuration)
+ .WriteTo.Console())
+ .ConfigureServices((ctx, services) =>
+ {
+ var connectionString = ctx.Configuration["App:ConnectionString"]
+ ?? throw new InvalidOperationException("App:ConnectionString is not configured.");
+
+ services.AddDbContext(options =>
+ options.UseNpgsql(connectionString));
+ })
+ .Build();
+
+ using var scope = host.Services.CreateScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+
+ Log.Information("Applying migrations...");
+ await db.Database.MigrateAsync();
+ Log.Information("Migrations applied successfully.");
+}
+catch (Exception ex)
+{
+ Log.Fatal(ex, "Migration failed.");
+ return 1;
+}
+finally
+{
+ await Log.CloseAndFlushAsync();
+}
+
+return 0;
diff --git a/HrynCo.NotificationService.Migrator/appsettings.json b/HrynCo.NotificationService.Migrator/appsettings.json
new file mode 100644
index 0000000..530da5d
--- /dev/null
+++ b/HrynCo.NotificationService.Migrator/appsettings.json
@@ -0,0 +1,18 @@
+{
+ "App": {
+ "ConnectionString": ""
+ },
+ "Serilog": {
+ "MinimumLevel": {
+ "Default": "Information",
+ "Override": {
+ "Microsoft": "Warning",
+ "Microsoft.EntityFrameworkCore": "Information"
+ }
+ },
+ "WriteTo": [
+ { "Name": "Console" }
+ ],
+ "Enrich": [ "FromLogContext" ]
+ }
+}
diff --git a/HrynCo.NotificationService.Services.Tests/HrynCo.NotificationService.Services.Tests.csproj b/HrynCo.NotificationService.Services.Tests/HrynCo.NotificationService.Services.Tests.csproj
new file mode 100644
index 0000000..69e51f0
--- /dev/null
+++ b/HrynCo.NotificationService.Services.Tests/HrynCo.NotificationService.Services.Tests.csproj
@@ -0,0 +1,26 @@
+
+
+
+ net10.0
+ enable
+ enable
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/HrynCo.NotificationService.Services.Tests/UnitTest1.cs b/HrynCo.NotificationService.Services.Tests/UnitTest1.cs
new file mode 100644
index 0000000..3c7bcd3
--- /dev/null
+++ b/HrynCo.NotificationService.Services.Tests/UnitTest1.cs
@@ -0,0 +1,10 @@
+namespace HrynCo.NotificationService.Services.Tests;
+
+public class UnitTest1
+{
+ [Fact]
+ public void Test1()
+ {
+
+ }
+}
diff --git a/HrynCo.NotificationService.Services/Behaviors/TransactionBehavior.cs b/HrynCo.NotificationService.Services/Behaviors/TransactionBehavior.cs
new file mode 100644
index 0000000..db7592a
--- /dev/null
+++ b/HrynCo.NotificationService.Services/Behaviors/TransactionBehavior.cs
@@ -0,0 +1,28 @@
+using HrynCo.Common;
+using HrynCo.NotificationService.DAL.Abstract;
+using MediatR;
+
+namespace HrynCo.NotificationService.Services.Behaviors;
+
+public class TransactionBehavior : IPipelineBehavior
+ where TRequest : notnull
+{
+ private readonly IUnitOfWork _unitOfWork;
+ private readonly IProfiler _profiler;
+
+ public TransactionBehavior(IUnitOfWork unitOfWork, IProfiler profiler)
+ {
+ _unitOfWork = unitOfWork;
+ _profiler = profiler;
+ }
+
+ public Task Handle(TRequest request, RequestHandlerDelegate next, CancellationToken cancellationToken) =>
+ _profiler.MeasureExecutionAsync(
+ () => _unitOfWork.ExecuteInTransactionAsync(async () =>
+ {
+ TResponse response = await next();
+ await _unitOfWork.SaveChangesAsync(cancellationToken);
+ return response;
+ }),
+ typeof(TRequest).Name);
+}
\ No newline at end of file
diff --git a/HrynCo.NotificationService.Services/Core/RequestHandler.cs b/HrynCo.NotificationService.Services/Core/RequestHandler.cs
new file mode 100644
index 0000000..b40264e
--- /dev/null
+++ b/HrynCo.NotificationService.Services/Core/RequestHandler.cs
@@ -0,0 +1,26 @@
+using HrynCo.NotificationService.DAL.Abstract;
+using HrynCo.NotificationService.Services.Logging;
+using MediatR;
+using Serilog;
+
+namespace HrynCo.NotificationService.Services.Core;
+
+public abstract class RequestHandler : IRequestHandler
+ where TRequest : IRequest
+{
+ protected RequestHandler(IContextualSerilogLogger logger, IUnitOfWork unitOfWork)
+ {
+ Logger = logger.Logger;
+ UnitOfWork = unitOfWork;
+ }
+
+ protected ILogger Logger { get; }
+ protected IUnitOfWork UnitOfWork { get; }
+
+ public Task Handle(TRequest request, CancellationToken cancellationToken)
+ {
+ return DoOnHandle(request, cancellationToken);
+ }
+
+ protected abstract Task DoOnHandle(TRequest request, CancellationToken cancellationToken);
+}
\ No newline at end of file
diff --git a/HrynCo.NotificationService.Services/Core/ServiceError.cs b/HrynCo.NotificationService.Services/Core/ServiceError.cs
new file mode 100644
index 0000000..0d084f6
--- /dev/null
+++ b/HrynCo.NotificationService.Services/Core/ServiceError.cs
@@ -0,0 +1,13 @@
+namespace HrynCo.NotificationService.Services.Core;
+
+public sealed record ServiceError
+{
+ public ServiceError(string message, ServiceErrorCode? code = null)
+ {
+ Message = message;
+ Code = code;
+ }
+
+ public ServiceErrorCode? Code { get; set; }
+ public string Message { get; set; }
+}
diff --git a/HrynCo.NotificationService.Services/Core/ServiceErrorCode.cs b/HrynCo.NotificationService.Services/Core/ServiceErrorCode.cs
new file mode 100644
index 0000000..1eca23f
--- /dev/null
+++ b/HrynCo.NotificationService.Services/Core/ServiceErrorCode.cs
@@ -0,0 +1,8 @@
+namespace HrynCo.NotificationService.Services.Core;
+
+public enum ServiceErrorCode
+{
+ NotFound = 1,
+ Conflict = 2,
+ InvalidRequest = 3,
+}
diff --git a/HrynCo.NotificationService.Services/Core/ServiceResult.cs b/HrynCo.NotificationService.Services/Core/ServiceResult.cs
new file mode 100644
index 0000000..28f1292
--- /dev/null
+++ b/HrynCo.NotificationService.Services/Core/ServiceResult.cs
@@ -0,0 +1,17 @@
+namespace HrynCo.NotificationService.Services.Core;
+
+public record ServiceResult
+{
+ public ServiceError? Error { get; private set; }
+ public bool IsSuccess { get; private set; }
+ public TResult? Result { get; private set; }
+
+ public static ServiceResult Success(TResult result) =>
+ new() { IsSuccess = true, Result = result };
+
+ public static ServiceResult Failure(ServiceError error) =>
+ new() { IsSuccess = false, Error = error };
+
+ public static ServiceResult Failure(string message, ServiceErrorCode? code = null) =>
+ Failure(new ServiceError(message, code));
+}
diff --git a/HrynCo.NotificationService.Services/Core/ServiceResultHelper.cs b/HrynCo.NotificationService.Services/Core/ServiceResultHelper.cs
new file mode 100644
index 0000000..b98490e
--- /dev/null
+++ b/HrynCo.NotificationService.Services/Core/ServiceResultHelper.cs
@@ -0,0 +1,9 @@
+namespace HrynCo.NotificationService.Services.Core;
+
+public static class ServiceResultHelper
+{
+ public static ServiceResult Success(T result) => ServiceResult.Success(result);
+
+ public static ServiceResult Failure(string errorMessage, ServiceErrorCode? errorCode = null) =>
+ ServiceResult.Failure(errorMessage, errorCode);
+}
diff --git a/HrynCo.NotificationService.Services/Core/Unit.cs b/HrynCo.NotificationService.Services/Core/Unit.cs
new file mode 100644
index 0000000..7b36323
--- /dev/null
+++ b/HrynCo.NotificationService.Services/Core/Unit.cs
@@ -0,0 +1,6 @@
+namespace HrynCo.NotificationService.Services.Core;
+
+public readonly struct Unit
+{
+ public static readonly Unit Value = new();
+}
diff --git a/HrynCo.NotificationService.Services/EmailChannels/Create/CreateEmailChannelCommand.cs b/HrynCo.NotificationService.Services/EmailChannels/Create/CreateEmailChannelCommand.cs
new file mode 100644
index 0000000..1045c73
--- /dev/null
+++ b/HrynCo.NotificationService.Services/EmailChannels/Create/CreateEmailChannelCommand.cs
@@ -0,0 +1,16 @@
+using HrynCo.NotificationService.DAL.Abstract.Providers;
+using MediatR;
+using HrynCo.NotificationService.Services.Core;
+
+namespace HrynCo.NotificationService.Services.EmailChannels.Create;
+
+public sealed record CreateEmailChannelCommand(
+ string ServiceName,
+ int Priority,
+ EmailChannelType ChannelType,
+ EmailChannelSettings Settings,
+ int? DailyLimit,
+ int? MonthlyLimit,
+ int WarnThresholdPercent,
+ bool IsActive
+) : IRequest>;
diff --git a/HrynCo.NotificationService.Services/EmailChannels/Create/CreateEmailChannelHandler.cs b/HrynCo.NotificationService.Services/EmailChannels/Create/CreateEmailChannelHandler.cs
new file mode 100644
index 0000000..7538abe
--- /dev/null
+++ b/HrynCo.NotificationService.Services/EmailChannels/Create/CreateEmailChannelHandler.cs
@@ -0,0 +1,43 @@
+using HrynCo.NotificationService.DAL.Abstract;
+using HrynCo.NotificationService.DAL.Abstract.Providers;
+using HrynCo.NotificationService.DAL.Abstract.Repositories;
+using HrynCo.NotificationService.Services.Core;
+using HrynCo.NotificationService.Services.Logging;
+using static HrynCo.NotificationService.Services.Core.ServiceResultHelper;
+
+namespace HrynCo.NotificationService.Services.EmailChannels.Create;
+
+internal sealed class CreateEmailChannelHandler
+ : RequestHandler>
+{
+ private readonly IEmailChannelRepository _channels;
+
+ public CreateEmailChannelHandler(
+ IContextualSerilogLogger logger,
+ IUnitOfWork unitOfWork,
+ IEmailChannelRepository channels)
+ : base(logger, unitOfWork)
+ {
+ _channels = channels;
+ }
+
+ protected override async Task> DoOnHandle(
+ CreateEmailChannelCommand request, CancellationToken cancellationToken)
+ {
+ var channel = new EmailChannel
+ {
+ ServiceName = request.ServiceName,
+ Priority = request.Priority,
+ EmailChannelType = request.ChannelType,
+ Settings = request.Settings,
+ DailyLimit = request.DailyLimit,
+ MonthlyLimit = request.MonthlyLimit,
+ WarnThresholdPercent = request.WarnThresholdPercent,
+ IsActive = request.IsActive
+ };
+
+ await _channels.AddAsync(channel, cancellationToken);
+
+ return Success(channel.Id);
+ }
+}
diff --git a/HrynCo.NotificationService.Services/EmailChannels/Delete/DeleteEmailChannelCommand.cs b/HrynCo.NotificationService.Services/EmailChannels/Delete/DeleteEmailChannelCommand.cs
new file mode 100644
index 0000000..f5dfb40
--- /dev/null
+++ b/HrynCo.NotificationService.Services/EmailChannels/Delete/DeleteEmailChannelCommand.cs
@@ -0,0 +1,8 @@
+using MediatR;
+using HrynCo.NotificationService.Services.Core;
+using Unit = HrynCo.NotificationService.Services.Core.Unit;
+
+namespace HrynCo.NotificationService.Services.EmailChannels.Delete;
+
+public sealed record DeleteEmailChannelCommand(Guid Id)
+ : IRequest>;
diff --git a/HrynCo.NotificationService.Services/EmailChannels/Delete/DeleteEmailChannelHandler.cs b/HrynCo.NotificationService.Services/EmailChannels/Delete/DeleteEmailChannelHandler.cs
new file mode 100644
index 0000000..9d126cd
--- /dev/null
+++ b/HrynCo.NotificationService.Services/EmailChannels/Delete/DeleteEmailChannelHandler.cs
@@ -0,0 +1,35 @@
+using HrynCo.NotificationService.DAL.Abstract;
+using HrynCo.NotificationService.DAL.Abstract.Repositories;
+using HrynCo.NotificationService.Services.Core;
+using HrynCo.NotificationService.Services.Logging;
+using static HrynCo.NotificationService.Services.Core.ServiceResultHelper;
+
+namespace HrynCo.NotificationService.Services.EmailChannels.Delete;
+
+internal sealed class DeleteEmailChannelHandler
+ : RequestHandler>
+{
+ private readonly IEmailChannelRepository _channels;
+
+ public DeleteEmailChannelHandler(
+ IContextualSerilogLogger logger,
+ IUnitOfWork unitOfWork,
+ IEmailChannelRepository channels)
+ : base(logger, unitOfWork)
+ {
+ _channels = channels;
+ }
+
+ protected override async Task> DoOnHandle(
+ DeleteEmailChannelCommand request, CancellationToken cancellationToken)
+ {
+ var channel = await _channels.GetByIdAsync(request.Id, cancellationToken);
+
+ if (channel is null)
+ return Failure("Email channel not found.", ServiceErrorCode.NotFound);
+
+ await _channels.DeleteAsync(channel, cancellationToken);
+
+ return Success(Unit.Value);
+ }
+}
diff --git a/HrynCo.NotificationService.Services/EmailChannels/Get/GetEmailChannelHandler.cs b/HrynCo.NotificationService.Services/EmailChannels/Get/GetEmailChannelHandler.cs
new file mode 100644
index 0000000..a85d45d
--- /dev/null
+++ b/HrynCo.NotificationService.Services/EmailChannels/Get/GetEmailChannelHandler.cs
@@ -0,0 +1,33 @@
+using HrynCo.NotificationService.DAL.Abstract;
+using HrynCo.NotificationService.DAL.Abstract.Providers;
+using HrynCo.NotificationService.DAL.Abstract.Repositories;
+using HrynCo.NotificationService.Services.Core;
+using HrynCo.NotificationService.Services.Logging;
+using static HrynCo.NotificationService.Services.Core.ServiceResultHelper;
+
+namespace HrynCo.NotificationService.Services.EmailChannels.Get;
+
+internal sealed class GetEmailChannelHandler
+ : RequestHandler>
+{
+ private readonly IEmailChannelRepository _channels;
+
+ public GetEmailChannelHandler(
+ IContextualSerilogLogger logger,
+ IUnitOfWork unitOfWork,
+ IEmailChannelRepository channels)
+ : base(logger, unitOfWork)
+ {
+ _channels = channels;
+ }
+
+ protected override async Task> DoOnHandle(
+ GetEmailChannelQuery request, CancellationToken cancellationToken)
+ {
+ var channel = await _channels.GetByIdAsync(request.Id, cancellationToken);
+
+ return channel is null
+ ? Failure("Email channel not found.", ServiceErrorCode.NotFound)
+ : Success(channel);
+ }
+}
diff --git a/HrynCo.NotificationService.Services/EmailChannels/Get/GetEmailChannelQuery.cs b/HrynCo.NotificationService.Services/EmailChannels/Get/GetEmailChannelQuery.cs
new file mode 100644
index 0000000..d105cee
--- /dev/null
+++ b/HrynCo.NotificationService.Services/EmailChannels/Get/GetEmailChannelQuery.cs
@@ -0,0 +1,8 @@
+using HrynCo.NotificationService.DAL.Abstract.Providers;
+using MediatR;
+using HrynCo.NotificationService.Services.Core;
+
+namespace HrynCo.NotificationService.Services.EmailChannels.Get;
+
+public sealed record GetEmailChannelQuery(Guid Id)
+ : IRequest>;
diff --git a/HrynCo.NotificationService.Services/EmailChannels/GetAll/GetAllEmailChannelsHandler.cs b/HrynCo.NotificationService.Services/EmailChannels/GetAll/GetAllEmailChannelsHandler.cs
new file mode 100644
index 0000000..91299ef
--- /dev/null
+++ b/HrynCo.NotificationService.Services/EmailChannels/GetAll/GetAllEmailChannelsHandler.cs
@@ -0,0 +1,30 @@
+using HrynCo.NotificationService.DAL.Abstract;
+using HrynCo.NotificationService.DAL.Abstract.Providers;
+using HrynCo.NotificationService.DAL.Abstract.Repositories;
+using HrynCo.NotificationService.Services.Core;
+using HrynCo.NotificationService.Services.Logging;
+using static HrynCo.NotificationService.Services.Core.ServiceResultHelper;
+
+namespace HrynCo.NotificationService.Services.EmailChannels.GetAll;
+
+internal sealed class GetAllEmailChannelsHandler
+ : RequestHandler>>
+{
+ private readonly IEmailChannelRepository _channels;
+
+ public GetAllEmailChannelsHandler(
+ IContextualSerilogLogger logger,
+ IUnitOfWork unitOfWork,
+ IEmailChannelRepository channels)
+ : base(logger, unitOfWork)
+ {
+ _channels = channels;
+ }
+
+ protected override async Task>> DoOnHandle(
+ GetAllEmailChannelsQuery request, CancellationToken cancellationToken)
+ {
+ var channels = await _channels.GetAllAsync(cancellationToken);
+ return Success(channels);
+ }
+}
diff --git a/HrynCo.NotificationService.Services/EmailChannels/GetAll/GetAllEmailChannelsQuery.cs b/HrynCo.NotificationService.Services/EmailChannels/GetAll/GetAllEmailChannelsQuery.cs
new file mode 100644
index 0000000..d9eb447
--- /dev/null
+++ b/HrynCo.NotificationService.Services/EmailChannels/GetAll/GetAllEmailChannelsQuery.cs
@@ -0,0 +1,7 @@
+using HrynCo.NotificationService.DAL.Abstract.Providers;
+using MediatR;
+using HrynCo.NotificationService.Services.Core;
+
+namespace HrynCo.NotificationService.Services.EmailChannels.GetAll;
+
+public sealed record GetAllEmailChannelsQuery : IRequest>>;
diff --git a/HrynCo.NotificationService.Services/EmailChannels/GetByService/GetEmailChannelsHandler.cs b/HrynCo.NotificationService.Services/EmailChannels/GetByService/GetEmailChannelsHandler.cs
new file mode 100644
index 0000000..b8af894
--- /dev/null
+++ b/HrynCo.NotificationService.Services/EmailChannels/GetByService/GetEmailChannelsHandler.cs
@@ -0,0 +1,30 @@
+using HrynCo.NotificationService.DAL.Abstract;
+using HrynCo.NotificationService.DAL.Abstract.Providers;
+using HrynCo.NotificationService.DAL.Abstract.Repositories;
+using HrynCo.NotificationService.Services.Core;
+using HrynCo.NotificationService.Services.Logging;
+using static HrynCo.NotificationService.Services.Core.ServiceResultHelper;
+
+namespace HrynCo.NotificationService.Services.EmailChannels.GetByService;
+
+internal sealed class GetEmailChannelsHandler
+ : RequestHandler>>
+{
+ private readonly IEmailChannelRepository _channels;
+
+ public GetEmailChannelsHandler(
+ IContextualSerilogLogger logger,
+ IUnitOfWork unitOfWork,
+ IEmailChannelRepository channels)
+ : base(logger, unitOfWork)
+ {
+ _channels = channels;
+ }
+
+ protected override async Task>> DoOnHandle(
+ GetEmailChannelsQuery request, CancellationToken cancellationToken)
+ {
+ var channels = await _channels.GetByServiceAsync(request.ServiceName, cancellationToken);
+ return Success(channels);
+ }
+}
diff --git a/HrynCo.NotificationService.Services/EmailChannels/GetByService/GetEmailChannelsQuery.cs b/HrynCo.NotificationService.Services/EmailChannels/GetByService/GetEmailChannelsQuery.cs
new file mode 100644
index 0000000..ddf7864
--- /dev/null
+++ b/HrynCo.NotificationService.Services/EmailChannels/GetByService/GetEmailChannelsQuery.cs
@@ -0,0 +1,8 @@
+using HrynCo.NotificationService.DAL.Abstract.Providers;
+using MediatR;
+using HrynCo.NotificationService.Services.Core;
+
+namespace HrynCo.NotificationService.Services.EmailChannels.GetByService;
+
+public sealed record GetEmailChannelsQuery(string ServiceName)
+ : IRequest>>;
diff --git a/HrynCo.NotificationService.Services/EmailChannels/GetUsageSummary/GetChannelUsageSummaryHandler.cs b/HrynCo.NotificationService.Services/EmailChannels/GetUsageSummary/GetChannelUsageSummaryHandler.cs
new file mode 100644
index 0000000..e9dc616
--- /dev/null
+++ b/HrynCo.NotificationService.Services/EmailChannels/GetUsageSummary/GetChannelUsageSummaryHandler.cs
@@ -0,0 +1,45 @@
+using HrynCo.NotificationService.DAL.Abstract;
+using HrynCo.NotificationService.DAL.Abstract.Repositories;
+using HrynCo.NotificationService.Services.Core;
+using HrynCo.NotificationService.Services.Logging;
+using static HrynCo.NotificationService.Services.Core.ServiceResultHelper;
+
+namespace HrynCo.NotificationService.Services.EmailChannels.GetUsageSummary;
+
+internal sealed class GetChannelUsageSummaryHandler
+ : RequestHandler>>
+{
+ private readonly IEmailChannelRepository _channelsRepository;
+
+ public GetChannelUsageSummaryHandler(
+ IContextualSerilogLogger logger,
+ IUnitOfWork unitOfWork,
+ IEmailChannelRepository channelsRepository)
+ : base(logger, unitOfWork)
+ {
+ _channelsRepository = channelsRepository;
+ }
+
+ protected override async Task>> DoOnHandle(
+ GetChannelUsageSummaryQuery request, CancellationToken cancellationToken)
+ {
+ var today = DateOnly.FromDateTime(DateTime.UtcNow);
+
+ var rows = await _channelsRepository.GetAllWithUsageSummaryAsync(today, cancellationToken);
+
+ var entries = rows
+ .Select(r => new ChannelUsageEntry(
+ ChannelId: r.Channel.Id,
+ ServiceName: r.Channel.ServiceName,
+ ChannelType: r.Channel.EmailChannelType.ToString(),
+ IsActive: r.Channel.IsActive,
+ Priority: r.Channel.Priority,
+ DailyLimit: r.Channel.DailyLimit,
+ MonthlyLimit: r.Channel.MonthlyLimit,
+ DailySent: r.DailySent,
+ MonthlySent: r.MonthlySent))
+ .ToList();
+
+ return Success>(entries);
+ }
+}
diff --git a/HrynCo.NotificationService.Services/EmailChannels/GetUsageSummary/GetChannelUsageSummaryQuery.cs b/HrynCo.NotificationService.Services/EmailChannels/GetUsageSummary/GetChannelUsageSummaryQuery.cs
new file mode 100644
index 0000000..f70a471
--- /dev/null
+++ b/HrynCo.NotificationService.Services/EmailChannels/GetUsageSummary/GetChannelUsageSummaryQuery.cs
@@ -0,0 +1,19 @@
+using HrynCo.NotificationService.DAL.Abstract.Providers;
+using HrynCo.NotificationService.Services.Core;
+using MediatR;
+
+namespace HrynCo.NotificationService.Services.EmailChannels.GetUsageSummary;
+
+public sealed record GetChannelUsageSummaryQuery
+ : IRequest>>;
+
+public sealed record ChannelUsageEntry(
+ Guid ChannelId,
+ string ServiceName,
+ string ChannelType,
+ bool IsActive,
+ int Priority,
+ int? DailyLimit,
+ int? MonthlyLimit,
+ int DailySent,
+ int MonthlySent);
diff --git a/HrynCo.NotificationService.Services/EmailChannels/Send/SendEmailCommand.cs b/HrynCo.NotificationService.Services/EmailChannels/Send/SendEmailCommand.cs
new file mode 100644
index 0000000..ef73316
--- /dev/null
+++ b/HrynCo.NotificationService.Services/EmailChannels/Send/SendEmailCommand.cs
@@ -0,0 +1,17 @@
+using HrynCo.NotificationService.Services.Core;
+using MediatR;
+
+namespace HrynCo.NotificationService.Services.EmailChannels.Send;
+
+///
+/// Sends an email via the channel associated with the given channel ID,
+/// then increments the usage counter for that channel.
+///
+public sealed record SendEmailCommand(
+ Guid ChannelId,
+ string RecipientEmail,
+ string RecipientName,
+ string Subject,
+ string HtmlBody,
+ string? TextBody
+) : IRequest>;
diff --git a/HrynCo.NotificationService.Services/EmailChannels/Send/SendEmailHandler.cs b/HrynCo.NotificationService.Services/EmailChannels/Send/SendEmailHandler.cs
new file mode 100644
index 0000000..8b6020b
--- /dev/null
+++ b/HrynCo.NotificationService.Services/EmailChannels/Send/SendEmailHandler.cs
@@ -0,0 +1,80 @@
+using System.Net;
+using System.Net.Mail;
+using HrynCo.NotificationService.DAL.Abstract;
+using HrynCo.NotificationService.DAL.Abstract.Providers;
+using HrynCo.NotificationService.DAL.Abstract.Repositories;
+using HrynCo.NotificationService.Services.Core;
+using HrynCo.NotificationService.Services.Logging;
+using static HrynCo.NotificationService.Services.Core.ServiceResultHelper;
+
+namespace HrynCo.NotificationService.Services.EmailChannels.Send;
+
+internal sealed class SendEmailHandler
+ : RequestHandler>
+{
+ private readonly IEmailChannelRepository _channels;
+ private readonly IEmailChannelUsageRepository _usage;
+
+ public SendEmailHandler(
+ IContextualSerilogLogger logger,
+ IUnitOfWork unitOfWork,
+ IEmailChannelRepository channels,
+ IEmailChannelUsageRepository usage)
+ : base(logger, unitOfWork)
+ {
+ _channels = channels;
+ _usage = usage;
+ }
+
+ protected override async Task> DoOnHandle(
+ SendEmailCommand request, CancellationToken cancellationToken)
+ {
+ var channel = await _channels.GetByIdAsync(request.ChannelId, cancellationToken);
+ if (channel is null)
+ return Failure($"Channel '{request.ChannelId}' not found.");
+
+ if (channel.Settings is not SmtpChannelSettings smtp)
+ return Failure($"Channel type '{channel.EmailChannelType}' is not supported for sending.");
+
+ try
+ {
+ using var client = new SmtpClient(smtp.Host, smtp.Port)
+ {
+ EnableSsl = smtp.UseSsl,
+ Credentials = string.IsNullOrWhiteSpace(smtp.Username)
+ ? null
+ : new NetworkCredential(smtp.Username, smtp.Password)
+ };
+
+ using var mail = new MailMessage
+ {
+ From = new MailAddress(smtp.FromEmail, smtp.FromName),
+ Subject = request.Subject,
+ Body = request.HtmlBody,
+ IsBodyHtml = true
+ };
+
+ if (!string.IsNullOrWhiteSpace(request.TextBody))
+ {
+ var plain = AlternateView.CreateAlternateViewFromString(request.TextBody, null, "text/plain");
+ mail.AlternateViews.Add(plain);
+ }
+
+ mail.To.Add(new MailAddress(request.RecipientEmail, request.RecipientName));
+
+ await client.SendMailAsync(mail, cancellationToken);
+ }
+ catch (Exception ex)
+ {
+ Logger.Error(ex, "SMTP send failed for channel {ChannelId}", request.ChannelId);
+ return Failure(ex.Message);
+ }
+
+ await _usage.IncrementUsageAsync(
+ request.ChannelId,
+ DateOnly.FromDateTime(DateTime.UtcNow),
+ cancellationToken);
+
+ return Success(Unit.Value);
+ }
+}
diff --git a/HrynCo.NotificationService.Services/EmailChannels/Update/UpdateEmailChannelCommand.cs b/HrynCo.NotificationService.Services/EmailChannels/Update/UpdateEmailChannelCommand.cs
new file mode 100644
index 0000000..7f81669
--- /dev/null
+++ b/HrynCo.NotificationService.Services/EmailChannels/Update/UpdateEmailChannelCommand.cs
@@ -0,0 +1,16 @@
+using HrynCo.NotificationService.DAL.Abstract.Providers;
+using MediatR;
+using HrynCo.NotificationService.Services.Core;
+using Unit = HrynCo.NotificationService.Services.Core.Unit;
+
+namespace HrynCo.NotificationService.Services.EmailChannels.Update;
+
+public sealed record UpdateEmailChannelCommand(
+ Guid Id,
+ int Priority,
+ EmailChannelSettings Settings,
+ int? DailyLimit,
+ int? MonthlyLimit,
+ int WarnThresholdPercent,
+ bool IsActive
+) : IRequest>;
diff --git a/HrynCo.NotificationService.Services/EmailChannels/Update/UpdateEmailChannelHandler.cs b/HrynCo.NotificationService.Services/EmailChannels/Update/UpdateEmailChannelHandler.cs
new file mode 100644
index 0000000..5c8f1ed
--- /dev/null
+++ b/HrynCo.NotificationService.Services/EmailChannels/Update/UpdateEmailChannelHandler.cs
@@ -0,0 +1,43 @@
+using HrynCo.NotificationService.DAL.Abstract;
+using HrynCo.NotificationService.DAL.Abstract.Repositories;
+using HrynCo.NotificationService.Services.Core;
+using HrynCo.NotificationService.Services.Logging;
+using static HrynCo.NotificationService.Services.Core.ServiceResultHelper;
+
+namespace HrynCo.NotificationService.Services.EmailChannels.Update;
+
+internal sealed class UpdateEmailChannelHandler
+ : RequestHandler>
+{
+ private readonly IEmailChannelRepository _channels;
+
+ public UpdateEmailChannelHandler(
+ IContextualSerilogLogger logger,
+ IUnitOfWork unitOfWork,
+ IEmailChannelRepository channels)
+ : base(logger, unitOfWork)
+ {
+ _channels = channels;
+ }
+
+ protected override async Task> DoOnHandle(
+ UpdateEmailChannelCommand request, CancellationToken cancellationToken)
+ {
+ var channel = await _channels.GetByIdAsync(request.Id, cancellationToken);
+
+ if (channel is null)
+ return Failure("Email channel not found.", ServiceErrorCode.NotFound);
+
+ channel.Priority = request.Priority;
+ channel.Settings = request.Settings;
+ channel.DailyLimit = request.DailyLimit;
+ channel.MonthlyLimit = request.MonthlyLimit;
+ channel.WarnThresholdPercent = request.WarnThresholdPercent;
+ channel.IsActive = request.IsActive;
+ channel.Updated = DateTimeOffset.UtcNow;
+
+ await _channels.UpdateAsync(channel, cancellationToken);
+
+ return Success(Unit.Value);
+ }
+}
diff --git a/HrynCo.NotificationService.Services/EmailTemplates/Create/CreateEmailTemplateCommand.cs b/HrynCo.NotificationService.Services/EmailTemplates/Create/CreateEmailTemplateCommand.cs
new file mode 100644
index 0000000..6792aae
--- /dev/null
+++ b/HrynCo.NotificationService.Services/EmailTemplates/Create/CreateEmailTemplateCommand.cs
@@ -0,0 +1,15 @@
+using HrynCo.NotificationService.DAL.Abstract.Templates;
+using MediatR;
+using HrynCo.NotificationService.Services.Core;
+
+namespace HrynCo.NotificationService.Services.EmailTemplates.Create;
+
+public sealed record CreateEmailTemplateCommand(
+ string ServiceName,
+ string Key,
+ string LanguageCode,
+ string Subject,
+ string HtmlBody,
+ string TextBody,
+ IReadOnlyList Variables
+) : IRequest>;
diff --git a/HrynCo.NotificationService.Services/EmailTemplates/Create/CreateEmailTemplateHandler.cs b/HrynCo.NotificationService.Services/EmailTemplates/Create/CreateEmailTemplateHandler.cs
new file mode 100644
index 0000000..33c44e0
--- /dev/null
+++ b/HrynCo.NotificationService.Services/EmailTemplates/Create/CreateEmailTemplateHandler.cs
@@ -0,0 +1,48 @@
+using HrynCo.NotificationService.DAL.Abstract;
+using HrynCo.NotificationService.DAL.Abstract.Repositories;
+using HrynCo.NotificationService.DAL.Abstract.Templates;
+using HrynCo.NotificationService.Services.Core;
+using HrynCo.NotificationService.Services.Logging;
+using static HrynCo.NotificationService.Services.Core.ServiceResultHelper;
+
+namespace HrynCo.NotificationService.Services.EmailTemplates.Create;
+
+internal sealed class CreateEmailTemplateHandler
+ : RequestHandler>
+{
+ private readonly IEmailTemplateRepository _templates;
+
+ public CreateEmailTemplateHandler(
+ IContextualSerilogLogger logger,
+ IUnitOfWork unitOfWork,
+ IEmailTemplateRepository templates)
+ : base(logger, unitOfWork)
+ {
+ _templates = templates;
+ }
+
+ protected override async Task> DoOnHandle(
+ CreateEmailTemplateCommand request, CancellationToken cancellationToken)
+ {
+ var existing = await _templates.GetAsync(
+ request.ServiceName, request.Key, request.LanguageCode, cancellationToken);
+
+ if (existing is not null)
+ return Failure("Template already exists.", ServiceErrorCode.Conflict);
+
+ var template = new EmailTemplate
+ {
+ ServiceName = request.ServiceName,
+ Key = request.Key,
+ LanguageCode = request.LanguageCode,
+ Subject = request.Subject,
+ HtmlBody = request.HtmlBody,
+ TextBody = request.TextBody,
+ Variables = request.Variables
+ };
+
+ await _templates.AddAsync(template, cancellationToken);
+
+ return Success(template.Id);
+ }
+}
diff --git a/HrynCo.NotificationService.Services/EmailTemplates/Delete/DeleteEmailTemplateCommand.cs b/HrynCo.NotificationService.Services/EmailTemplates/Delete/DeleteEmailTemplateCommand.cs
new file mode 100644
index 0000000..e52dc73
--- /dev/null
+++ b/HrynCo.NotificationService.Services/EmailTemplates/Delete/DeleteEmailTemplateCommand.cs
@@ -0,0 +1,11 @@
+using MediatR;
+using HrynCo.NotificationService.Services.Core;
+using Unit = HrynCo.NotificationService.Services.Core.Unit;
+
+namespace HrynCo.NotificationService.Services.EmailTemplates.Delete;
+
+public sealed record DeleteEmailTemplateCommand(
+ string ServiceName,
+ string Key,
+ string LanguageCode
+) : IRequest>;
diff --git a/HrynCo.NotificationService.Services/EmailTemplates/Delete/DeleteEmailTemplateHandler.cs b/HrynCo.NotificationService.Services/EmailTemplates/Delete/DeleteEmailTemplateHandler.cs
new file mode 100644
index 0000000..4e924c0
--- /dev/null
+++ b/HrynCo.NotificationService.Services/EmailTemplates/Delete/DeleteEmailTemplateHandler.cs
@@ -0,0 +1,36 @@
+using HrynCo.NotificationService.DAL.Abstract;
+using HrynCo.NotificationService.DAL.Abstract.Repositories;
+using HrynCo.NotificationService.Services.Core;
+using HrynCo.NotificationService.Services.Logging;
+using static HrynCo.NotificationService.Services.Core.ServiceResultHelper;
+
+namespace HrynCo.NotificationService.Services.EmailTemplates.Delete;
+
+internal sealed class DeleteEmailTemplateHandler
+ : RequestHandler>
+{
+ private readonly IEmailTemplateRepository _templates;
+
+ public DeleteEmailTemplateHandler(
+ IContextualSerilogLogger logger,
+ IUnitOfWork unitOfWork,
+ IEmailTemplateRepository templates)
+ : base(logger, unitOfWork)
+ {
+ _templates = templates;
+ }
+
+ protected override async Task> DoOnHandle(
+ DeleteEmailTemplateCommand request, CancellationToken cancellationToken)
+ {
+ var template = await _templates.GetAsync(
+ request.ServiceName, request.Key, request.LanguageCode, cancellationToken);
+
+ if (template is null)
+ return Failure("Template not found.", ServiceErrorCode.NotFound);
+
+ await _templates.DeleteAsync(template, cancellationToken);
+
+ return Success(Unit.Value);
+ }
+}
diff --git a/HrynCo.NotificationService.Services/EmailTemplates/Get/GetEmailTemplateHandler.cs b/HrynCo.NotificationService.Services/EmailTemplates/Get/GetEmailTemplateHandler.cs
new file mode 100644
index 0000000..24b1f75
--- /dev/null
+++ b/HrynCo.NotificationService.Services/EmailTemplates/Get/GetEmailTemplateHandler.cs
@@ -0,0 +1,34 @@
+using HrynCo.NotificationService.DAL.Abstract;
+using HrynCo.NotificationService.DAL.Abstract.Repositories;
+using HrynCo.NotificationService.DAL.Abstract.Templates;
+using HrynCo.NotificationService.Services.Core;
+using HrynCo.NotificationService.Services.Logging;
+using static HrynCo.NotificationService.Services.Core.ServiceResultHelper;
+
+namespace HrynCo.NotificationService.Services.EmailTemplates.Get;
+
+internal sealed class GetEmailTemplateHandler
+ : RequestHandler>
+{
+ private readonly IEmailTemplateRepository _templates;
+
+ public GetEmailTemplateHandler(
+ IContextualSerilogLogger logger,
+ IUnitOfWork unitOfWork,
+ IEmailTemplateRepository templates)
+ : base(logger, unitOfWork)
+ {
+ _templates = templates;
+ }
+
+ protected override async Task> DoOnHandle(
+ GetEmailTemplateQuery request, CancellationToken cancellationToken)
+ {
+ var template = await _templates.GetAsync(
+ request.ServiceName, request.Key, request.LanguageCode, cancellationToken);
+
+ return template is null
+ ? Failure("Template not found.", ServiceErrorCode.NotFound)
+ : Success(template);
+ }
+}
diff --git a/HrynCo.NotificationService.Services/EmailTemplates/Get/GetEmailTemplateQuery.cs b/HrynCo.NotificationService.Services/EmailTemplates/Get/GetEmailTemplateQuery.cs
new file mode 100644
index 0000000..f87ab38
--- /dev/null
+++ b/HrynCo.NotificationService.Services/EmailTemplates/Get/GetEmailTemplateQuery.cs
@@ -0,0 +1,8 @@
+using HrynCo.NotificationService.DAL.Abstract.Templates;
+using MediatR;
+using HrynCo.NotificationService.Services.Core;
+
+namespace HrynCo.NotificationService.Services.EmailTemplates.Get;
+
+public sealed record GetEmailTemplateQuery(string ServiceName, string Key, string LanguageCode)
+ : IRequest>;
diff --git a/HrynCo.NotificationService.Services/EmailTemplates/GetAll/GetAllEmailTemplatesHandler.cs b/HrynCo.NotificationService.Services/EmailTemplates/GetAll/GetAllEmailTemplatesHandler.cs
new file mode 100644
index 0000000..3ee21d2
--- /dev/null
+++ b/HrynCo.NotificationService.Services/EmailTemplates/GetAll/GetAllEmailTemplatesHandler.cs
@@ -0,0 +1,30 @@
+using HrynCo.NotificationService.DAL.Abstract;
+using HrynCo.NotificationService.DAL.Abstract.Repositories;
+using HrynCo.NotificationService.DAL.Abstract.Templates;
+using HrynCo.NotificationService.Services.Core;
+using HrynCo.NotificationService.Services.Logging;
+using static HrynCo.NotificationService.Services.Core.ServiceResultHelper;
+
+namespace HrynCo.NotificationService.Services.EmailTemplates.GetAll;
+
+internal sealed class GetAllEmailTemplatesHandler
+ : RequestHandler>>
+{
+ private readonly IEmailTemplateRepository _templates;
+
+ public GetAllEmailTemplatesHandler(
+ IContextualSerilogLogger logger,
+ IUnitOfWork unitOfWork,
+ IEmailTemplateRepository templates)
+ : base(logger, unitOfWork)
+ {
+ _templates = templates;
+ }
+
+ protected override async Task>> DoOnHandle(
+ GetAllEmailTemplatesQuery request, CancellationToken cancellationToken)
+ {
+ var templates = await _templates.GetAllAsync(cancellationToken);
+ return Success(templates);
+ }
+}
diff --git a/HrynCo.NotificationService.Services/EmailTemplates/GetAll/GetAllEmailTemplatesQuery.cs b/HrynCo.NotificationService.Services/EmailTemplates/GetAll/GetAllEmailTemplatesQuery.cs
new file mode 100644
index 0000000..7d9c13d
--- /dev/null
+++ b/HrynCo.NotificationService.Services/EmailTemplates/GetAll/GetAllEmailTemplatesQuery.cs
@@ -0,0 +1,7 @@
+using HrynCo.NotificationService.DAL.Abstract.Templates;
+using MediatR;
+using HrynCo.NotificationService.Services.Core;
+
+namespace HrynCo.NotificationService.Services.EmailTemplates.GetAll;
+
+public sealed record GetAllEmailTemplatesQuery : IRequest>>;
diff --git a/HrynCo.NotificationService.Services/EmailTemplates/GetByService/GetEmailTemplatesHandler.cs b/HrynCo.NotificationService.Services/EmailTemplates/GetByService/GetEmailTemplatesHandler.cs
new file mode 100644
index 0000000..7fb5fc0
--- /dev/null
+++ b/HrynCo.NotificationService.Services/EmailTemplates/GetByService/GetEmailTemplatesHandler.cs
@@ -0,0 +1,30 @@
+using HrynCo.NotificationService.DAL.Abstract;
+using HrynCo.NotificationService.DAL.Abstract.Repositories;
+using HrynCo.NotificationService.DAL.Abstract.Templates;
+using HrynCo.NotificationService.Services.Core;
+using HrynCo.NotificationService.Services.Logging;
+using static HrynCo.NotificationService.Services.Core.ServiceResultHelper;
+
+namespace HrynCo.NotificationService.Services.EmailTemplates.GetByService;
+
+internal sealed class GetEmailTemplatesHandler
+ : RequestHandler>>
+{
+ private readonly IEmailTemplateRepository _templates;
+
+ public GetEmailTemplatesHandler(
+ IContextualSerilogLogger logger,
+ IUnitOfWork unitOfWork,
+ IEmailTemplateRepository templates)
+ : base(logger, unitOfWork)
+ {
+ _templates = templates;
+ }
+
+ protected override async Task>> DoOnHandle(
+ GetEmailTemplatesQuery request, CancellationToken cancellationToken)
+ {
+ var templates = await _templates.GetByServiceAsync(request.ServiceName, cancellationToken);
+ return Success(templates);
+ }
+}
diff --git a/HrynCo.NotificationService.Services/EmailTemplates/GetByService/GetEmailTemplatesQuery.cs b/HrynCo.NotificationService.Services/EmailTemplates/GetByService/GetEmailTemplatesQuery.cs
new file mode 100644
index 0000000..70dbb2d
--- /dev/null
+++ b/HrynCo.NotificationService.Services/EmailTemplates/GetByService/GetEmailTemplatesQuery.cs
@@ -0,0 +1,8 @@
+using HrynCo.NotificationService.DAL.Abstract.Templates;
+using MediatR;
+using HrynCo.NotificationService.Services.Core;
+
+namespace HrynCo.NotificationService.Services.EmailTemplates.GetByService;
+
+public sealed record GetEmailTemplatesQuery(string ServiceName)
+ : IRequest>>;
diff --git a/HrynCo.NotificationService.Services/EmailTemplates/Update/UpdateEmailTemplateCommand.cs b/HrynCo.NotificationService.Services/EmailTemplates/Update/UpdateEmailTemplateCommand.cs
new file mode 100644
index 0000000..fca25be
--- /dev/null
+++ b/HrynCo.NotificationService.Services/EmailTemplates/Update/UpdateEmailTemplateCommand.cs
@@ -0,0 +1,16 @@
+using HrynCo.NotificationService.DAL.Abstract.Templates;
+using MediatR;
+using HrynCo.NotificationService.Services.Core;
+using Unit = HrynCo.NotificationService.Services.Core.Unit;
+
+namespace HrynCo.NotificationService.Services.EmailTemplates.Update;
+
+public sealed record UpdateEmailTemplateCommand(
+ string ServiceName,
+ string Key,
+ string LanguageCode,
+ string Subject,
+ string HtmlBody,
+ string TextBody,
+ IReadOnlyList Variables
+) : IRequest>;
diff --git a/HrynCo.NotificationService.Services/EmailTemplates/Update/UpdateEmailTemplateHandler.cs b/HrynCo.NotificationService.Services/EmailTemplates/Update/UpdateEmailTemplateHandler.cs
new file mode 100644
index 0000000..597e200
--- /dev/null
+++ b/HrynCo.NotificationService.Services/EmailTemplates/Update/UpdateEmailTemplateHandler.cs
@@ -0,0 +1,42 @@
+using HrynCo.NotificationService.DAL.Abstract;
+using HrynCo.NotificationService.DAL.Abstract.Repositories;
+using HrynCo.NotificationService.Services.Core;
+using HrynCo.NotificationService.Services.Logging;
+using static HrynCo.NotificationService.Services.Core.ServiceResultHelper;
+
+namespace HrynCo.NotificationService.Services.EmailTemplates.Update;
+
+internal sealed class UpdateEmailTemplateHandler
+ : RequestHandler>
+{
+ private readonly IEmailTemplateRepository _templates;
+
+ public UpdateEmailTemplateHandler(
+ IContextualSerilogLogger logger,
+ IUnitOfWork unitOfWork,
+ IEmailTemplateRepository templates)
+ : base(logger, unitOfWork)
+ {
+ _templates = templates;
+ }
+
+ protected override async Task> DoOnHandle(
+ UpdateEmailTemplateCommand request, CancellationToken cancellationToken)
+ {
+ var template = await _templates.GetAsync(
+ request.ServiceName, request.Key, request.LanguageCode, cancellationToken);
+
+ if (template is null)
+ return Failure("Template not found.", ServiceErrorCode.NotFound);
+
+ template.Subject = request.Subject;
+ template.HtmlBody = request.HtmlBody;
+ template.TextBody = request.TextBody;
+ template.Variables = request.Variables;
+ template.Updated = DateTimeOffset.UtcNow;
+
+ await _templates.UpdateAsync(template, cancellationToken);
+
+ return Success(Unit.Value);
+ }
+}
diff --git a/HrynCo.NotificationService.Services/HrynCo.NotificationService.Services.csproj b/HrynCo.NotificationService.Services/HrynCo.NotificationService.Services.csproj
new file mode 100644
index 0000000..7cb964d
--- /dev/null
+++ b/HrynCo.NotificationService.Services/HrynCo.NotificationService.Services.csproj
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ net10.0
+ enable
+ enable
+
+
+
diff --git a/HrynCo.NotificationService.Services/Logging/ContextualSerilogLogger.cs b/HrynCo.NotificationService.Services/Logging/ContextualSerilogLogger.cs
new file mode 100644
index 0000000..53f43ec
--- /dev/null
+++ b/HrynCo.NotificationService.Services/Logging/ContextualSerilogLogger.cs
@@ -0,0 +1,8 @@
+using Serilog;
+
+namespace HrynCo.NotificationService.Services.Logging;
+
+public sealed class ContextualSerilogLogger : IContextualSerilogLogger
+{
+ public ILogger Logger { get; } = Log.ForContext();
+}
\ No newline at end of file
diff --git a/HrynCo.NotificationService.Services/Logging/IContextualSerilogLogger.cs b/HrynCo.NotificationService.Services/Logging/IContextualSerilogLogger.cs
new file mode 100644
index 0000000..718de10
--- /dev/null
+++ b/HrynCo.NotificationService.Services/Logging/IContextualSerilogLogger.cs
@@ -0,0 +1,11 @@
+using System.Diagnostics.CodeAnalysis;
+using Serilog;
+
+namespace HrynCo.NotificationService.Services.Logging;
+
+[SuppressMessage("Major Code Smell", "S2326:Unused type parameters should be removed",
+ Justification = "Generic parameter used in implementation via ForContext