diff --git a/Directory.Packages.props b/Directory.Packages.props index 44e5aa1..170d1c4 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -30,7 +30,7 @@ - + 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/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/Repositories/IEmailChannelRepository.cs b/HrynCo.NotificationService.DAL.Abstract/Repositories/IEmailChannelRepository.cs index 89e984b..a0647d6 100644 --- a/HrynCo.NotificationService.DAL.Abstract/Repositories/IEmailChannelRepository.cs +++ b/HrynCo.NotificationService.DAL.Abstract/Repositories/IEmailChannelRepository.cs @@ -7,6 +7,7 @@ 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 index e7c8bdf..809ec88 100644 --- a/HrynCo.NotificationService.DAL.Abstract/Repositories/IEmailChannelUsageRepository.cs +++ b/HrynCo.NotificationService.DAL.Abstract/Repositories/IEmailChannelUsageRepository.cs @@ -1,5 +1,3 @@ -using HrynCo.NotificationService.DAL.Abstract.Providers; - namespace HrynCo.NotificationService.DAL.Abstract.Repositories; public interface IEmailChannelUsageRepository diff --git a/HrynCo.NotificationService.DAL.EF/Configurations/EmailChannelEntityConfiguration.cs b/HrynCo.NotificationService.DAL.EF/Configurations/EmailChannelEntityConfiguration.cs index 0869846..8b560f6 100644 --- a/HrynCo.NotificationService.DAL.EF/Configurations/EmailChannelEntityConfiguration.cs +++ b/HrynCo.NotificationService.DAL.EF/Configurations/EmailChannelEntityConfiguration.cs @@ -35,5 +35,9 @@ internal class EmailChannelEntityConfiguration : IEntityTypeConfiguration 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/Entities/EmailChannelEntity.cs b/HrynCo.NotificationService.DAL.EF/Entities/EmailChannelEntity.cs index 577544b..4db02b4 100644 --- a/HrynCo.NotificationService.DAL.EF/Entities/EmailChannelEntity.cs +++ b/HrynCo.NotificationService.DAL.EF/Entities/EmailChannelEntity.cs @@ -19,4 +19,6 @@ internal class EmailChannelEntity : Entity 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/Repositories/EmailChannelRepository.cs b/HrynCo.NotificationService.DAL.EF/Repositories/EmailChannelRepository.cs index e471dee..81f2210 100644 --- a/HrynCo.NotificationService.DAL.EF/Repositories/EmailChannelRepository.cs +++ b/HrynCo.NotificationService.DAL.EF/Repositories/EmailChannelRepository.cs @@ -35,6 +35,30 @@ internal sealed class EmailChannelRepository : EfRepository, 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); 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.Web/Controllers/Admin/AdminChannelsController.cs b/HrynCo.NotificationService.Web/Controllers/Admin/AdminChannelsController.cs index 69333f8..be74a99 100644 --- a/HrynCo.NotificationService.Web/Controllers/Admin/AdminChannelsController.cs +++ b/HrynCo.NotificationService.Web/Controllers/Admin/AdminChannelsController.cs @@ -1,36 +1,30 @@ using HrynCo.NotificationService.DAL.Abstract.Providers; +using HrynCo.NotificationService.DAL.Abstract.Repositories; using HrynCo.NotificationService.Services.EmailChannels.Create; using HrynCo.NotificationService.Services.EmailChannels.Delete; using HrynCo.NotificationService.Services.EmailChannels.Get; -using HrynCo.NotificationService.Services.EmailChannels.GetAll; +using HrynCo.NotificationService.Services.EmailChannels.GetUsageSummary; +using HrynCo.NotificationService.Services.EmailChannels.Send; using HrynCo.NotificationService.Services.EmailChannels.Update; using HrynCo.NotificationService.Web.Controllers.Admin.ViewModels; using MediatR; using Microsoft.AspNetCore.Mvc; -using System.Net; -using System.Net.Mail; namespace HrynCo.NotificationService.Web.Controllers.Admin; [Route("admin/channels")] -public class AdminChannelsController : Controller +public class AdminChannelsController(IMediator mediator) : Controller { - private readonly IMediator _mediator; - - public AdminChannelsController(IMediator mediator) - { - _mediator = mediator; - } // GET /admin/channels [HttpGet("")] public async Task Index(CancellationToken ct) { - var result = await _mediator.Send(new GetAllEmailChannelsQuery(), ct); + var result = await mediator.Send(new GetChannelUsageSummaryQuery(), ct); if (!result.IsSuccess) { ModelState.AddModelError("", result.Error?.Message ?? "Failed to load channels."); - return View(Array.Empty()); + return View(Array.Empty()); } return View(result.Result); @@ -47,7 +41,7 @@ public class AdminChannelsController : Controller [HttpGet("{id:guid}")] public async Task Edit(Guid id, CancellationToken ct) { - var result = await _mediator.Send(new GetEmailChannelQuery(id), ct); + var result = await mediator.Send(new GetEmailChannelQuery(id), ct); if (!result.IsSuccess || result.Result is null) return NotFound(); @@ -110,7 +104,7 @@ public class AdminChannelsController : Controller model.WarnThresholdPercent, model.IsActive); - var result = await _mediator.Send(command, ct); + var result = await mediator.Send(command, ct); if (!result.IsSuccess) { ModelState.AddModelError("", result.Error?.Message ?? "Failed to create channel."); @@ -128,7 +122,7 @@ public class AdminChannelsController : Controller model.WarnThresholdPercent, model.IsActive); - var result = await _mediator.Send(command, ct); + var result = await mediator.Send(command, ct); if (!result.IsSuccess) { ModelState.AddModelError("", result.Error?.Message ?? "Failed to update channel."); @@ -143,40 +137,21 @@ public class AdminChannelsController : Controller [HttpPost("{id:guid}/test")] public async Task Test(Guid id, [FromBody] TestChannelRequest request, CancellationToken ct) { - var result = await _mediator.Send(new GetEmailChannelQuery(id), ct); - if (!result.IsSuccess || result.Result is null) + var channelResult = await mediator.Send(new GetEmailChannelQuery(id), ct); + if (!channelResult.IsSuccess || channelResult.Result is null) return NotFound(new { success = false, message = "Channel not found." }); - if (result.Result.Settings is not SmtpChannelSettings smtp) - return BadRequest(new { success = false, message = "Only SMTP channels are supported." }); + var channel = channelResult.Result; + var subject = "✅ Test email from Notification Service"; + var body = $"This is a test email sent from the Notification Service admin panel.\n\nChannel: {channel.ServiceName}"; - 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) - }; + var sendResult = await mediator.Send( + new SendEmailCommand(id, request.ToEmail, request.ToEmail, subject, body, null), ct); - var message = new MailMessage - { - From = new MailAddress(smtp.FromEmail, smtp.FromName), - Subject = "✅ Test email from Notification Service", - Body = $"This is a test email sent from the Notification Service admin panel.\n\nChannel: {result.Result.ServiceName}\nHost: {smtp.Host}:{smtp.Port}", - IsBodyHtml = false - }; - message.To.Add(request.ToEmail); + if (!sendResult.IsSuccess) + return Ok(new { success = false, message = sendResult.Error?.Message }); - await client.SendMailAsync(message, ct); - - return Ok(new { success = true, message = $"Test email sent to {request.ToEmail}." }); - } - catch (Exception ex) - { - return Ok(new { success = false, message = ex.Message }); - } + return Ok(new { success = true, message = $"Test email sent to {request.ToEmail}." }); } // POST /admin/channels/{id}/delete @@ -184,7 +159,7 @@ public class AdminChannelsController : Controller [ValidateAntiForgeryToken] public async Task Delete(Guid id, CancellationToken ct) { - await _mediator.Send(new DeleteEmailChannelCommand(id), ct); + await mediator.Send(new DeleteEmailChannelCommand(id), ct); return RedirectToAction(nameof(Index)); } } diff --git a/HrynCo.NotificationService.Web/Views/AdminChannels/Index.cshtml b/HrynCo.NotificationService.Web/Views/AdminChannels/Index.cshtml index 8ac187d..27ac40d 100644 --- a/HrynCo.NotificationService.Web/Views/AdminChannels/Index.cshtml +++ b/HrynCo.NotificationService.Web/Views/AdminChannels/Index.cshtml @@ -1,5 +1,5 @@ -@using HrynCo.NotificationService.DAL.Abstract.Providers -@model IReadOnlyList +@using HrynCo.NotificationService.Services.EmailChannels.GetUsageSummary +@model IReadOnlyList @{ ViewData["Title"] = "Email Channels"; } @@ -40,12 +40,12 @@ else - + - - + + @@ -54,7 +54,7 @@ else { - + - - + +
Service NameService Type Priority StatusDaily LimitMonthly LimitTodayThis Month Actions
@c.ServiceName@c.EmailChannelType@c.ChannelType @c.Priority @if (c.IsActive) @@ -66,15 +66,53 @@ else Inactive } @(c.DailyLimit.HasValue ? c.DailyLimit.ToString() : "—")@(c.MonthlyLimit.HasValue ? c.MonthlyLimit.ToString() : "—") + @{ + var dailyLabel = c.DailyLimit.HasValue ? $"{c.DailySent} / {c.DailyLimit}" : c.DailySent.ToString(); + } + @if (c.DailyLimit.HasValue && c.DailyLimit > 0) + { + var pct = Math.Min((double)c.DailySent / c.DailyLimit.Value * 100, 100); + var color = pct >= 100 ? "danger" : pct >= 90 ? "warning" : "success"; +
+
+
+
+ @dailyLabel +
+ } + else + { + @dailyLabel + } +
+ @{ + var monthlyLabel = c.MonthlyLimit.HasValue ? $"{c.MonthlySent} / {c.MonthlyLimit}" : c.MonthlySent.ToString(); + } + @if (c.MonthlyLimit.HasValue && c.MonthlyLimit > 0) + { + var pct = Math.Min((double)c.MonthlySent / c.MonthlyLimit.Value * 100, 100); + var color = pct >= 100 ? "danger" : pct >= 90 ? "warning" : "success"; +
+
+
+
+ @monthlyLabel +
+ } + else + { + @monthlyLabel + } +
- Edit
@Html.AntiForgeryToken()