feat: add RabbitMQ worker, contracts, usage UI in channels screen
- Add HrynCo.NotificationService.Contracts project with SendEmailMessage and NotificationResultMessage - Add SendEmailConsumer (RabbitMQ worker) with reply-to pattern via CorrelationContext.ReplyTo - Add SendEmailHandler owning SMTP send + usage increment as business logic - Add GetChannelUsageSummaryHandler with single DB query via navigation property - Merge usage stats inline into channels list (daily/monthly with progress bars) - Refactor AdminChannelsController.Index to use GetChannelUsageSummaryQuery - Add RabbitMQ service to docker-compose files - Remove dead AdminChannelUsageController, ChannelUsageViewModel, ChannelUsageSummary Ref: IT-628 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -30,7 +30,7 @@
|
|||||||
|
|
||||||
<!-- HrynCo shared packages -->
|
<!-- HrynCo shared packages -->
|
||||||
<PackageVersion Include="HrynCo.Common" Version="1.0.0" />
|
<PackageVersion Include="HrynCo.Common" Version="1.0.0" />
|
||||||
<PackageVersion Include="HrynCo.RabbitMq" Version="1.0.11" />
|
<PackageVersion Include="HrynCo.RabbitMq" Version="1.0.14" />
|
||||||
|
|
||||||
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||||
<PackageVersion Include="coverlet.collector" Version="6.0.4" />
|
<PackageVersion Include="coverlet.collector" Version="6.0.4" />
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="HrynCo.RabbitMq" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -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; }
|
||||||
|
|
||||||
|
/// <summary>Null when delivery succeeded; contains error details on failure.</summary>
|
||||||
|
public string? ErrorMessage { get; init; }
|
||||||
|
|
||||||
|
public bool IsSuccess => ErrorMessage is null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
namespace HrynCo.NotificationService.Contracts.Messages;
|
||||||
|
|
||||||
|
using Hrynco.RabbitMq;
|
||||||
|
|
||||||
|
public record NotificationResultMessage : IRabbitMqMessage<NotificationResultData>
|
||||||
|
{
|
||||||
|
public CorrelationContext CorrelationContext { get; set; } = null!;
|
||||||
|
public NotificationResultData Data { get; set; } = null!;
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
namespace HrynCo.NotificationService.Contracts.Messages;
|
||||||
|
|
||||||
|
using Hrynco.RabbitMq;
|
||||||
|
|
||||||
|
public record SendEmailMessage : IRabbitMqMessage<SendEmailMessageData>
|
||||||
|
{
|
||||||
|
public CorrelationContext CorrelationContext { get; set; } = default!;
|
||||||
|
public SendEmailMessageData Data { get; set; } = default!;
|
||||||
|
}
|
||||||
@@ -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<string, string> Variables { get; init; }
|
||||||
|
public string? LanguageCode { get; init; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
namespace HrynCo.NotificationService.DAL.Abstract.Providers;
|
||||||
|
|
||||||
|
public record ChannelWithUsage(
|
||||||
|
EmailChannel Channel,
|
||||||
|
int DailySent,
|
||||||
|
int MonthlySent);
|
||||||
@@ -7,6 +7,7 @@ public interface IEmailChannelRepository
|
|||||||
Task<IReadOnlyList<EmailChannel>> GetAllAsync(CancellationToken ct = default);
|
Task<IReadOnlyList<EmailChannel>> GetAllAsync(CancellationToken ct = default);
|
||||||
Task<IReadOnlyList<EmailChannel>> GetByServiceAsync(string serviceName, CancellationToken ct = default);
|
Task<IReadOnlyList<EmailChannel>> GetByServiceAsync(string serviceName, CancellationToken ct = default);
|
||||||
Task<EmailChannel?> GetByIdAsync(Guid id, CancellationToken ct = default);
|
Task<EmailChannel?> GetByIdAsync(Guid id, CancellationToken ct = default);
|
||||||
|
Task<IReadOnlyList<ChannelWithUsage>> GetAllWithUsageSummaryAsync(DateOnly today, CancellationToken ct = default);
|
||||||
Task AddAsync(EmailChannel channel, CancellationToken ct = default);
|
Task AddAsync(EmailChannel channel, CancellationToken ct = default);
|
||||||
Task UpdateAsync(EmailChannel channel, CancellationToken ct = default);
|
Task UpdateAsync(EmailChannel channel, CancellationToken ct = default);
|
||||||
Task DeleteAsync(EmailChannel channel, CancellationToken ct = default);
|
Task DeleteAsync(EmailChannel channel, CancellationToken ct = default);
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
using HrynCo.NotificationService.DAL.Abstract.Providers;
|
|
||||||
|
|
||||||
namespace HrynCo.NotificationService.DAL.Abstract.Repositories;
|
namespace HrynCo.NotificationService.DAL.Abstract.Repositories;
|
||||||
|
|
||||||
public interface IEmailChannelUsageRepository
|
public interface IEmailChannelUsageRepository
|
||||||
|
|||||||
@@ -35,5 +35,9 @@ internal class EmailChannelEntityConfiguration : IEntityTypeConfiguration<EmailC
|
|||||||
builder.Property(x => x.IsActive).HasColumnName("is_active");
|
builder.Property(x => x.IsActive).HasColumnName("is_active");
|
||||||
builder.Property(x => x.Created).HasColumnName("created");
|
builder.Property(x => x.Created).HasColumnName("created");
|
||||||
builder.Property(x => x.Updated).HasColumnName("updated");
|
builder.Property(x => x.Updated).HasColumnName("updated");
|
||||||
|
|
||||||
|
builder.HasMany(x => x.UsageRecords)
|
||||||
|
.WithOne()
|
||||||
|
.HasForeignKey(u => u.ProviderId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,4 +19,6 @@ internal class EmailChannelEntity : Entity
|
|||||||
public int? MonthlyLimit { get; set; }
|
public int? MonthlyLimit { get; set; }
|
||||||
public int WarnThresholdPercent { get; set; }
|
public int WarnThresholdPercent { get; set; }
|
||||||
public bool IsActive { get; set; }
|
public bool IsActive { get; set; }
|
||||||
|
|
||||||
|
public ICollection<EmailChannelUsageEntity> UsageRecords { get; set; } = [];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,30 @@ internal sealed class EmailChannelRepository : EfRepository<EmailChannelEntity>,
|
|||||||
return entities.Select(MapToDomain).ToList();
|
return entities.Select(MapToDomain).ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<ChannelWithUsage>> 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<EmailChannel?> GetByIdAsync(Guid id, CancellationToken ct = default)
|
public async Task<EmailChannel?> GetByIdAsync(Guid id, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
EmailChannelEntity? entity = await DbSet.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, ct);
|
EmailChannelEntity? entity = await DbSet.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, ct);
|
||||||
|
|||||||
+45
@@ -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<GetChannelUsageSummaryQuery, ServiceResult<IReadOnlyList<ChannelUsageEntry>>>
|
||||||
|
{
|
||||||
|
private readonly IEmailChannelRepository _channelsRepository;
|
||||||
|
|
||||||
|
public GetChannelUsageSummaryHandler(
|
||||||
|
IContextualSerilogLogger<GetChannelUsageSummaryQuery> logger,
|
||||||
|
IUnitOfWork unitOfWork,
|
||||||
|
IEmailChannelRepository channelsRepository)
|
||||||
|
: base(logger, unitOfWork)
|
||||||
|
{
|
||||||
|
_channelsRepository = channelsRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async Task<ServiceResult<IReadOnlyList<ChannelUsageEntry>>> 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<IReadOnlyList<ChannelUsageEntry>>(entries);
|
||||||
|
}
|
||||||
|
}
|
||||||
+19
@@ -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<ServiceResult<IReadOnlyList<ChannelUsageEntry>>>;
|
||||||
|
|
||||||
|
public sealed record ChannelUsageEntry(
|
||||||
|
Guid ChannelId,
|
||||||
|
string ServiceName,
|
||||||
|
string ChannelType,
|
||||||
|
bool IsActive,
|
||||||
|
int Priority,
|
||||||
|
int? DailyLimit,
|
||||||
|
int? MonthlyLimit,
|
||||||
|
int DailySent,
|
||||||
|
int MonthlySent);
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
using HrynCo.NotificationService.Services.Core;
|
||||||
|
using MediatR;
|
||||||
|
|
||||||
|
namespace HrynCo.NotificationService.Services.EmailChannels.Send;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sends an email via the channel associated with the given channel ID,
|
||||||
|
/// then increments the usage counter for that channel.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record SendEmailCommand(
|
||||||
|
Guid ChannelId,
|
||||||
|
string RecipientEmail,
|
||||||
|
string RecipientName,
|
||||||
|
string Subject,
|
||||||
|
string HtmlBody,
|
||||||
|
string? TextBody
|
||||||
|
) : IRequest<ServiceResult<Core.Unit>>;
|
||||||
@@ -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<SendEmailCommand, ServiceResult<Core.Unit>>
|
||||||
|
{
|
||||||
|
private readonly IEmailChannelRepository _channels;
|
||||||
|
private readonly IEmailChannelUsageRepository _usage;
|
||||||
|
|
||||||
|
public SendEmailHandler(
|
||||||
|
IContextualSerilogLogger<SendEmailCommand> logger,
|
||||||
|
IUnitOfWork unitOfWork,
|
||||||
|
IEmailChannelRepository channels,
|
||||||
|
IEmailChannelUsageRepository usage)
|
||||||
|
: base(logger, unitOfWork)
|
||||||
|
{
|
||||||
|
_channels = channels;
|
||||||
|
_usage = usage;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async Task<ServiceResult<Core.Unit>> DoOnHandle(
|
||||||
|
SendEmailCommand request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var channel = await _channels.GetByIdAsync(request.ChannelId, cancellationToken);
|
||||||
|
if (channel is null)
|
||||||
|
return Failure<Core.Unit>($"Channel '{request.ChannelId}' not found.");
|
||||||
|
|
||||||
|
if (channel.Settings is not SmtpChannelSettings smtp)
|
||||||
|
return Failure<Core.Unit>($"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<Core.Unit>(ex.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
await _usage.IncrementUsageAsync(
|
||||||
|
request.ChannelId,
|
||||||
|
DateOnly.FromDateTime(DateTime.UtcNow),
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
return Success(Unit.Value);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,36 +1,30 @@
|
|||||||
using HrynCo.NotificationService.DAL.Abstract.Providers;
|
using HrynCo.NotificationService.DAL.Abstract.Providers;
|
||||||
|
using HrynCo.NotificationService.DAL.Abstract.Repositories;
|
||||||
using HrynCo.NotificationService.Services.EmailChannels.Create;
|
using HrynCo.NotificationService.Services.EmailChannels.Create;
|
||||||
using HrynCo.NotificationService.Services.EmailChannels.Delete;
|
using HrynCo.NotificationService.Services.EmailChannels.Delete;
|
||||||
using HrynCo.NotificationService.Services.EmailChannels.Get;
|
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.Services.EmailChannels.Update;
|
||||||
using HrynCo.NotificationService.Web.Controllers.Admin.ViewModels;
|
using HrynCo.NotificationService.Web.Controllers.Admin.ViewModels;
|
||||||
using MediatR;
|
using MediatR;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using System.Net;
|
|
||||||
using System.Net.Mail;
|
|
||||||
|
|
||||||
namespace HrynCo.NotificationService.Web.Controllers.Admin;
|
namespace HrynCo.NotificationService.Web.Controllers.Admin;
|
||||||
|
|
||||||
[Route("admin/channels")]
|
[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
|
// GET /admin/channels
|
||||||
[HttpGet("")]
|
[HttpGet("")]
|
||||||
public async Task<IActionResult> Index(CancellationToken ct)
|
public async Task<IActionResult> Index(CancellationToken ct)
|
||||||
{
|
{
|
||||||
var result = await _mediator.Send(new GetAllEmailChannelsQuery(), ct);
|
var result = await mediator.Send(new GetChannelUsageSummaryQuery(), ct);
|
||||||
if (!result.IsSuccess)
|
if (!result.IsSuccess)
|
||||||
{
|
{
|
||||||
ModelState.AddModelError("", result.Error?.Message ?? "Failed to load channels.");
|
ModelState.AddModelError("", result.Error?.Message ?? "Failed to load channels.");
|
||||||
return View(Array.Empty<EmailChannel>());
|
return View(Array.Empty<ChannelUsageEntry>());
|
||||||
}
|
}
|
||||||
|
|
||||||
return View(result.Result);
|
return View(result.Result);
|
||||||
@@ -47,7 +41,7 @@ public class AdminChannelsController : Controller
|
|||||||
[HttpGet("{id:guid}")]
|
[HttpGet("{id:guid}")]
|
||||||
public async Task<IActionResult> Edit(Guid id, CancellationToken ct)
|
public async Task<IActionResult> 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)
|
if (!result.IsSuccess || result.Result is null)
|
||||||
return NotFound();
|
return NotFound();
|
||||||
|
|
||||||
@@ -110,7 +104,7 @@ public class AdminChannelsController : Controller
|
|||||||
model.WarnThresholdPercent,
|
model.WarnThresholdPercent,
|
||||||
model.IsActive);
|
model.IsActive);
|
||||||
|
|
||||||
var result = await _mediator.Send(command, ct);
|
var result = await mediator.Send(command, ct);
|
||||||
if (!result.IsSuccess)
|
if (!result.IsSuccess)
|
||||||
{
|
{
|
||||||
ModelState.AddModelError("", result.Error?.Message ?? "Failed to create channel.");
|
ModelState.AddModelError("", result.Error?.Message ?? "Failed to create channel.");
|
||||||
@@ -128,7 +122,7 @@ public class AdminChannelsController : Controller
|
|||||||
model.WarnThresholdPercent,
|
model.WarnThresholdPercent,
|
||||||
model.IsActive);
|
model.IsActive);
|
||||||
|
|
||||||
var result = await _mediator.Send(command, ct);
|
var result = await mediator.Send(command, ct);
|
||||||
if (!result.IsSuccess)
|
if (!result.IsSuccess)
|
||||||
{
|
{
|
||||||
ModelState.AddModelError("", result.Error?.Message ?? "Failed to update channel.");
|
ModelState.AddModelError("", result.Error?.Message ?? "Failed to update channel.");
|
||||||
@@ -143,48 +137,29 @@ public class AdminChannelsController : Controller
|
|||||||
[HttpPost("{id:guid}/test")]
|
[HttpPost("{id:guid}/test")]
|
||||||
public async Task<IActionResult> Test(Guid id, [FromBody] TestChannelRequest request, CancellationToken ct)
|
public async Task<IActionResult> Test(Guid id, [FromBody] TestChannelRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var result = await _mediator.Send(new GetEmailChannelQuery(id), ct);
|
var channelResult = await mediator.Send(new GetEmailChannelQuery(id), ct);
|
||||||
if (!result.IsSuccess || result.Result is null)
|
if (!channelResult.IsSuccess || channelResult.Result is null)
|
||||||
return NotFound(new { success = false, message = "Channel not found." });
|
return NotFound(new { success = false, message = "Channel not found." });
|
||||||
|
|
||||||
if (result.Result.Settings is not SmtpChannelSettings smtp)
|
var channel = channelResult.Result;
|
||||||
return BadRequest(new { success = false, message = "Only SMTP channels are supported." });
|
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
|
var sendResult = await mediator.Send(
|
||||||
{
|
new SendEmailCommand(id, request.ToEmail, request.ToEmail, subject, body, null), ct);
|
||||||
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 message = new MailMessage
|
if (!sendResult.IsSuccess)
|
||||||
{
|
return Ok(new { success = false, message = sendResult.Error?.Message });
|
||||||
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);
|
|
||||||
|
|
||||||
await client.SendMailAsync(message, ct);
|
|
||||||
|
|
||||||
return Ok(new { success = true, message = $"Test email sent to {request.ToEmail}." });
|
return Ok(new { success = true, message = $"Test email sent to {request.ToEmail}." });
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
return Ok(new { success = false, message = ex.Message });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// POST /admin/channels/{id}/delete
|
// POST /admin/channels/{id}/delete
|
||||||
[HttpPost("{id:guid}/delete")]
|
[HttpPost("{id:guid}/delete")]
|
||||||
[ValidateAntiForgeryToken]
|
[ValidateAntiForgeryToken]
|
||||||
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
||||||
{
|
{
|
||||||
await _mediator.Send(new DeleteEmailChannelCommand(id), ct);
|
await mediator.Send(new DeleteEmailChannelCommand(id), ct);
|
||||||
return RedirectToAction(nameof(Index));
|
return RedirectToAction(nameof(Index));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
@using HrynCo.NotificationService.DAL.Abstract.Providers
|
@using HrynCo.NotificationService.Services.EmailChannels.GetUsageSummary
|
||||||
@model IReadOnlyList<EmailChannel>
|
@model IReadOnlyList<ChannelUsageEntry>
|
||||||
@{
|
@{
|
||||||
ViewData["Title"] = "Email Channels";
|
ViewData["Title"] = "Email Channels";
|
||||||
}
|
}
|
||||||
@@ -40,12 +40,12 @@ else
|
|||||||
<table class="table table-hover table-sm mb-0">
|
<table class="table table-hover table-sm mb-0">
|
||||||
<thead class="table-dark">
|
<thead class="table-dark">
|
||||||
<tr>
|
<tr>
|
||||||
<th>Service Name</th>
|
<th>Service</th>
|
||||||
<th>Type</th>
|
<th>Type</th>
|
||||||
<th>Priority</th>
|
<th>Priority</th>
|
||||||
<th>Status</th>
|
<th>Status</th>
|
||||||
<th>Daily Limit</th>
|
<th>Today</th>
|
||||||
<th>Monthly Limit</th>
|
<th>This Month</th>
|
||||||
<th class="text-end">Actions</th>
|
<th class="text-end">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -54,7 +54,7 @@ else
|
|||||||
{
|
{
|
||||||
<tr>
|
<tr>
|
||||||
<td>@c.ServiceName</td>
|
<td>@c.ServiceName</td>
|
||||||
<td>@c.EmailChannelType</td>
|
<td>@c.ChannelType</td>
|
||||||
<td>@c.Priority</td>
|
<td>@c.Priority</td>
|
||||||
<td>
|
<td>
|
||||||
@if (c.IsActive)
|
@if (c.IsActive)
|
||||||
@@ -66,15 +66,53 @@ else
|
|||||||
<span class="badge bg-secondary text-muted">Inactive</span>
|
<span class="badge bg-secondary text-muted">Inactive</span>
|
||||||
}
|
}
|
||||||
</td>
|
</td>
|
||||||
<td>@(c.DailyLimit.HasValue ? c.DailyLimit.ToString() : "—")</td>
|
<td style="min-width:130px">
|
||||||
<td>@(c.MonthlyLimit.HasValue ? c.MonthlyLimit.ToString() : "—")</td>
|
@{
|
||||||
|
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";
|
||||||
|
<div class="d-flex align-items-center gap-2">
|
||||||
|
<div class="progress flex-grow-1" style="height:6px; min-width:60px">
|
||||||
|
<div class="progress-bar bg-@color" style="width:@pct.ToString("F0")%"></div>
|
||||||
|
</div>
|
||||||
|
<small class="text-nowrap text-muted">@dailyLabel</small>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<small class="text-muted">@dailyLabel</small>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td style="min-width:130px">
|
||||||
|
@{
|
||||||
|
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";
|
||||||
|
<div class="d-flex align-items-center gap-2">
|
||||||
|
<div class="progress flex-grow-1" style="height:6px; min-width:60px">
|
||||||
|
<div class="progress-bar bg-@color" style="width:@pct.ToString("F0")%"></div>
|
||||||
|
</div>
|
||||||
|
<small class="text-nowrap text-muted">@monthlyLabel</small>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<small class="text-muted">@monthlyLabel</small>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
<td class="text-end">
|
<td class="text-end">
|
||||||
<a href="/admin/channels/@c.Id"
|
<a href="/admin/channels/@c.ChannelId"
|
||||||
class="btn btn-sm btn-outline-primary me-1">
|
class="btn btn-sm btn-outline-primary me-1">
|
||||||
<i class="bi bi-pencil"></i> Edit
|
<i class="bi bi-pencil"></i> Edit
|
||||||
</a>
|
</a>
|
||||||
<form method="post"
|
<form method="post"
|
||||||
action="/admin/channels/@c.Id/delete"
|
action="/admin/channels/@c.ChannelId/delete"
|
||||||
class="d-inline">
|
class="d-inline">
|
||||||
@Html.AntiForgeryToken()
|
@Html.AntiForgeryToken()
|
||||||
<button type="submit"
|
<button type="submit"
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using Hrynco.RabbitMq;
|
||||||
|
|
||||||
namespace HrynCo.NotificationService.Worker;
|
namespace HrynCo.NotificationService.Worker;
|
||||||
|
|
||||||
public sealed class AppSettings
|
public sealed class AppSettings
|
||||||
@@ -5,4 +7,5 @@ public sealed class AppSettings
|
|||||||
public const string SectionName = "App";
|
public const string SectionName = "App";
|
||||||
|
|
||||||
public string ConnectionString { get; init; } = string.Empty;
|
public string ConnectionString { get; init; } = string.Empty;
|
||||||
|
public RabbitMqSettings RabbitMq { get; init; } = null!;
|
||||||
}
|
}
|
||||||
@@ -9,6 +9,8 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.Extensions.Hosting" />
|
<PackageReference Include="Microsoft.Extensions.Hosting" />
|
||||||
|
<PackageReference Include="HrynCo.RabbitMq" />
|
||||||
|
<PackageReference Include="MediatR" />
|
||||||
<PackageReference Include="Serilog.Extensions.Hosting" />
|
<PackageReference Include="Serilog.Extensions.Hosting" />
|
||||||
<PackageReference Include="Serilog.Settings.Configuration" />
|
<PackageReference Include="Serilog.Settings.Configuration" />
|
||||||
<PackageReference Include="Serilog.Sinks.Console" />
|
<PackageReference Include="Serilog.Sinks.Console" />
|
||||||
@@ -16,6 +18,7 @@
|
|||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\HrynCo.NotificationService.Contracts\HrynCo.NotificationService.Contracts.csproj" />
|
||||||
<ProjectReference Include="..\HrynCo.NotificationService.Services\HrynCo.NotificationService.Services.csproj" />
|
<ProjectReference Include="..\HrynCo.NotificationService.Services\HrynCo.NotificationService.Services.csproj" />
|
||||||
<ProjectReference Include="..\HrynCo.NotificationService.DAL.EF\HrynCo.NotificationService.DAL.EF.csproj" />
|
<ProjectReference Include="..\HrynCo.NotificationService.DAL.EF\HrynCo.NotificationService.DAL.EF.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
using HrynCo.NotificationService.DAL.EF;
|
using HrynCo.NotificationService.DAL.EF;
|
||||||
using HrynCo.NotificationService.Services;
|
using HrynCo.NotificationService.Services;
|
||||||
using HrynCo.NotificationService.Worker;
|
using HrynCo.NotificationService.Worker;
|
||||||
|
using Hrynco.RabbitMq;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
var builder = Host.CreateApplicationBuilder(args);
|
var builder = Host.CreateApplicationBuilder(args);
|
||||||
|
|
||||||
@@ -13,7 +15,11 @@ var appSettings = builder.Configuration
|
|||||||
builder.Services.AddSingleton(appSettings);
|
builder.Services.AddSingleton(appSettings);
|
||||||
builder.Services.AddNotificationDataAccess(appSettings.ConnectionString);
|
builder.Services.AddNotificationDataAccess(appSettings.ConnectionString);
|
||||||
builder.Services.AddNotificationServices();
|
builder.Services.AddNotificationServices();
|
||||||
builder.Services.AddHostedService<Worker>();
|
|
||||||
|
builder.Services.Configure<RabbitMqSettings>(
|
||||||
|
builder.Configuration.GetSection($"{AppSettings.SectionName}:RabbitMq"));
|
||||||
|
|
||||||
|
builder.Services.AddHostedService<SendEmailConsumer>();
|
||||||
|
|
||||||
var host = builder.Build();
|
var host = builder.Build();
|
||||||
host.Run();
|
host.Run();
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
namespace HrynCo.NotificationService.Worker;
|
||||||
|
|
||||||
|
public record RenderedEmail(string Subject, string HtmlBody, string TextBody);
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
namespace HrynCo.NotificationService.Worker;
|
||||||
|
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using HrynCo.NotificationService.Contracts.Messages;
|
||||||
|
using HrynCo.NotificationService.DAL.Abstract.Providers;
|
||||||
|
using HrynCo.NotificationService.DAL.Abstract.Repositories;
|
||||||
|
using HrynCo.NotificationService.DAL.Abstract.Templates;
|
||||||
|
using HrynCo.NotificationService.Services.EmailChannels.Send;
|
||||||
|
using Hrynco.RabbitMq;
|
||||||
|
using MediatR;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using RabbitMQ.Client;
|
||||||
|
|
||||||
|
public sealed class SendEmailConsumer(
|
||||||
|
IOptionsMonitor<RabbitMqSettings> options,
|
||||||
|
IEmailChannelRepository channelRepository,
|
||||||
|
IEmailTemplateRepository templateRepository,
|
||||||
|
IEmailChannelUsageRepository usageRepository,
|
||||||
|
IMediator mediator,
|
||||||
|
AppSettings appSettings,
|
||||||
|
ILogger<SendEmailConsumer> logger)
|
||||||
|
: RabbitMqConsumerBase<SendEmailMessage, SendEmailMessageData>(options, logger)
|
||||||
|
{
|
||||||
|
private const string IncomingQueue = "notification.send-email";
|
||||||
|
|
||||||
|
protected override string QueueName => IncomingQueue;
|
||||||
|
|
||||||
|
protected override async Task HandleMessageAsync(SendEmailMessage message, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var data = message.Data;
|
||||||
|
|
||||||
|
logger.LogInformation(
|
||||||
|
"Processing SendEmail for service={Service} template={Template} recipient={Recipient} [CorrelationId={CorrelationId}]",
|
||||||
|
data.ServiceName, data.TemplateKey, data.RecipientEmail, message.CorrelationContext?.CorrelationId);
|
||||||
|
|
||||||
|
var channel = await ResolveChannelAsync(data.ServiceName, cancellationToken);
|
||||||
|
var template = await ResolveTemplateAsync(data.ServiceName, data.TemplateKey, data.LanguageCode, cancellationToken);
|
||||||
|
|
||||||
|
await EnforceLimitsAsync(channel, cancellationToken);
|
||||||
|
|
||||||
|
var rendered = RenderTemplate(template, data);
|
||||||
|
|
||||||
|
var sendResult = await mediator.Send(
|
||||||
|
new SendEmailCommand(channel.Id, data.RecipientEmail, data.RecipientName,
|
||||||
|
rendered.Subject, rendered.HtmlBody, rendered.TextBody),
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
if (!sendResult.IsSuccess)
|
||||||
|
throw new InvalidOperationException(sendResult.Error?.Message ?? "Send failed.");
|
||||||
|
|
||||||
|
logger.LogInformation(
|
||||||
|
"Email sent successfully service={Service} template={Template} recipient={Recipient}",
|
||||||
|
data.ServiceName, data.TemplateKey, data.RecipientEmail);
|
||||||
|
|
||||||
|
await PublishResultAsync(message.CorrelationContext, data, errorMessage: null, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<EmailChannel> ResolveChannelAsync(string serviceName, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var channels = await channelRepository.GetByServiceAsync(serviceName, ct);
|
||||||
|
|
||||||
|
return channels
|
||||||
|
.Where(c => c.IsActive)
|
||||||
|
.OrderBy(c => c.Priority)
|
||||||
|
.FirstOrDefault()
|
||||||
|
?? throw new InvalidOperationException(
|
||||||
|
$"No active email channel found for service '{serviceName}'.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<EmailTemplate> ResolveTemplateAsync(
|
||||||
|
string serviceName, string templateKey, string? languageCode, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var lang = string.IsNullOrWhiteSpace(languageCode) ? "en" : languageCode;
|
||||||
|
var template = await templateRepository.GetAsync(serviceName, templateKey, lang, ct);
|
||||||
|
|
||||||
|
if (template is null && lang != "en")
|
||||||
|
template = await templateRepository.GetAsync(serviceName, templateKey, "en", ct);
|
||||||
|
|
||||||
|
return template
|
||||||
|
?? throw new InvalidOperationException(
|
||||||
|
$"Template not found: service='{serviceName}' key='{templateKey}' language='{lang}'.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task EnforceLimitsAsync(EmailChannel channel, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var today = DateOnly.FromDateTime(DateTime.UtcNow);
|
||||||
|
|
||||||
|
if (channel.DailyLimit.HasValue)
|
||||||
|
{
|
||||||
|
var daily = await usageRepository.GetDailyCountAsync(channel.Id, today, ct);
|
||||||
|
if (daily >= channel.DailyLimit.Value)
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Channel '{channel.Id}' daily limit of {channel.DailyLimit.Value} reached ({daily} sent today).");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (channel.MonthlyLimit.HasValue)
|
||||||
|
{
|
||||||
|
var monthly = await usageRepository.GetMonthlyCountAsync(channel.Id, today.Year, today.Month, ct);
|
||||||
|
if (monthly >= channel.MonthlyLimit.Value)
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Channel '{channel.Id}' monthly limit of {channel.MonthlyLimit.Value} reached ({monthly} sent this month).");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static RenderedEmail RenderTemplate(EmailTemplate template, SendEmailMessageData data)
|
||||||
|
{
|
||||||
|
return new RenderedEmail(
|
||||||
|
Interpolate(template.Subject, data.Variables),
|
||||||
|
Interpolate(template.HtmlBody, data.Variables),
|
||||||
|
Interpolate(template.TextBody, data.Variables));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Interpolate(string text, IReadOnlyDictionary<string, string> variables)
|
||||||
|
{
|
||||||
|
var sb = new StringBuilder(text);
|
||||||
|
foreach (var (key, value) in variables)
|
||||||
|
sb.Replace($"{{{{{key}}}}}", value);
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task PublishResultAsync(
|
||||||
|
CorrelationContext? correlationContext,
|
||||||
|
SendEmailMessageData data,
|
||||||
|
string? errorMessage,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
var replyTo = correlationContext?.ReplyTo;
|
||||||
|
if (string.IsNullOrWhiteSpace(replyTo))
|
||||||
|
return;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = new NotificationResultMessage
|
||||||
|
{
|
||||||
|
CorrelationContext = (correlationContext ?? new CorrelationContext { CorrelationId = Guid.NewGuid().ToString() }) with { ReplyTo = null },
|
||||||
|
Data = new NotificationResultData
|
||||||
|
{
|
||||||
|
ServiceName = data.ServiceName,
|
||||||
|
RecipientEmail = data.RecipientEmail,
|
||||||
|
TemplateKey = data.TemplateKey,
|
||||||
|
Timestamp = DateTimeOffset.UtcNow,
|
||||||
|
ErrorMessage = errorMessage
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
byte[] body = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(result));
|
||||||
|
|
||||||
|
var factory = new ConnectionFactory
|
||||||
|
{
|
||||||
|
HostName = appSettings.RabbitMq.Host,
|
||||||
|
Port = appSettings.RabbitMq.Port,
|
||||||
|
UserName = appSettings.RabbitMq.User,
|
||||||
|
Password = appSettings.RabbitMq.Password,
|
||||||
|
VirtualHost = appSettings.RabbitMq.VirtualHost
|
||||||
|
};
|
||||||
|
|
||||||
|
await using var conn = await factory.CreateConnectionAsync(ct);
|
||||||
|
await using var ch = await conn.CreateChannelAsync(cancellationToken: ct);
|
||||||
|
|
||||||
|
await ch.QueueDeclareAsync(replyTo, durable: true, exclusive: false, autoDelete: false,
|
||||||
|
cancellationToken: ct);
|
||||||
|
await ch.BasicPublishAsync(exchange: string.Empty, routingKey: replyTo, body: body,
|
||||||
|
cancellationToken: ct);
|
||||||
|
|
||||||
|
logger.LogDebug("Result published to reply queue '{Queue}' [CorrelationId={CorrelationId}]",
|
||||||
|
replyTo, correlationContext?.CorrelationId);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogWarning(ex, "Failed to publish notification result to reply queue '{Queue}'", replyTo);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
namespace HrynCo.NotificationService.Worker;
|
|
||||||
|
|
||||||
public class Worker(ILogger<Worker> logger) : BackgroundService
|
|
||||||
{
|
|
||||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
|
||||||
{
|
|
||||||
while (!stoppingToken.IsCancellationRequested)
|
|
||||||
{
|
|
||||||
if (logger.IsEnabled(LogLevel.Information))
|
|
||||||
{
|
|
||||||
logger.LogInformation("Worker running at: {time}", DateTimeOffset.Now);
|
|
||||||
}
|
|
||||||
await Task.Delay(1000, stoppingToken);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,13 @@
|
|||||||
{
|
{
|
||||||
"App": {
|
"App": {
|
||||||
"ConnectionString": "Host=localhost;Port=5432;Database=notification_service;Username=postgres;Password=postgres"
|
"ConnectionString": "Host=localhost;Port=5432;Database=notification_service;Username=postgres;Password=postgres",
|
||||||
|
"RabbitMq": {
|
||||||
|
"Host": "localhost",
|
||||||
|
"Port": 5672,
|
||||||
|
"User": "guest",
|
||||||
|
"Password": "guest",
|
||||||
|
"VirtualHost": "/"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"Serilog": {
|
"Serilog": {
|
||||||
"MinimumLevel": {
|
"MinimumLevel": {
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
<wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
|
||||||
|
<s:Boolean x:Key="/Default/UserDictionary/Words/=Hryn/@EntryIndexedValue">True</s:Boolean></wpf:ResourceDictionary>
|
||||||
@@ -15,6 +15,7 @@
|
|||||||
<Project Path="HrynCo.NotificationService.Migrator/HrynCo.NotificationService.Migrator.csproj" />
|
<Project Path="HrynCo.NotificationService.Migrator/HrynCo.NotificationService.Migrator.csproj" />
|
||||||
<Project Path="HrynCo.NotificationService.Web.IntegrationTests/HrynCo.NotificationService.Web.IntegrationTests.csproj" />
|
<Project Path="HrynCo.NotificationService.Web.IntegrationTests/HrynCo.NotificationService.Web.IntegrationTests.csproj" />
|
||||||
<Project Path="HrynCo.NotificationService.Web/HrynCo.NotificationService.Web.csproj" />
|
<Project Path="HrynCo.NotificationService.Web/HrynCo.NotificationService.Web.csproj" />
|
||||||
|
<Project Path="HrynCo.NotificationService.Contracts/HrynCo.NotificationService.Contracts.csproj" />
|
||||||
<Project Path="HrynCo.NotificationService.DAL.Abstract/HrynCo.NotificationService.DAL.Abstract.csproj" />
|
<Project Path="HrynCo.NotificationService.DAL.Abstract/HrynCo.NotificationService.DAL.Abstract.csproj" />
|
||||||
<Project Path="HrynCo.NotificationService.DAL.EF/HrynCo.NotificationService.DAL.EF.csproj" />
|
<Project Path="HrynCo.NotificationService.DAL.EF/HrynCo.NotificationService.DAL.EF.csproj" />
|
||||||
<Project Path="HrynCo.NotificationService.Services.Tests/HrynCo.NotificationService.Services.Tests.csproj" />
|
<Project Path="HrynCo.NotificationService.Services.Tests/HrynCo.NotificationService.Services.Tests.csproj" />
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
name: hrynco-notification-service
|
||||||
|
|
||||||
services:
|
services:
|
||||||
migrator:
|
migrator:
|
||||||
environment:
|
environment:
|
||||||
@@ -15,7 +17,29 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
- DOTNET_ENVIRONMENT=Development
|
- DOTNET_ENVIRONMENT=Development
|
||||||
- App__ConnectionString=Host=db;Port=5432;Database=notification_service;Username=postgres;Password=postgres
|
- App__ConnectionString=Host=db;Port=5432;Database=notification_service;Username=postgres;Password=postgres
|
||||||
|
- App__RabbitMq__Host=rabbitmq
|
||||||
|
- App__RabbitMq__User=guest
|
||||||
|
- App__RabbitMq__Password=guest
|
||||||
- Serilog__WriteTo__1__Args__serverUrl=http://seq
|
- Serilog__WriteTo__1__Args__serverUrl=http://seq
|
||||||
|
depends_on:
|
||||||
|
rabbitmq:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
rabbitmq:
|
||||||
|
image: rabbitmq:4-management-alpine
|
||||||
|
environment:
|
||||||
|
RABBITMQ_DEFAULT_USER: guest
|
||||||
|
RABBITMQ_DEFAULT_PASS: guest
|
||||||
|
ports:
|
||||||
|
- "5672:5672"
|
||||||
|
- "15672:15672"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "rabbitmq-diagnostics", "ping"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
volumes:
|
||||||
|
- notification_rabbitmq:/var/lib/rabbitmq
|
||||||
|
|
||||||
db:
|
db:
|
||||||
image: postgres:17
|
image: postgres:17
|
||||||
@@ -41,3 +65,4 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
notification_db:
|
notification_db:
|
||||||
notification_seq:
|
notification_seq:
|
||||||
|
notification_rabbitmq:
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
name: hrynco-notification-service
|
||||||
|
|
||||||
services:
|
services:
|
||||||
migrator:
|
migrator:
|
||||||
build:
|
build:
|
||||||
@@ -30,8 +32,24 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
- DOTNET_ENVIRONMENT=Production
|
- DOTNET_ENVIRONMENT=Production
|
||||||
- App__ConnectionString=${CONNECTION_STRING}
|
- App__ConnectionString=${CONNECTION_STRING}
|
||||||
|
- App__RabbitMq__Host=rabbitmq
|
||||||
|
- App__RabbitMq__User=${RABBITMQ_USER:-guest}
|
||||||
|
- App__RabbitMq__Password=${RABBITMQ_PASSWORD:-guest}
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_started
|
condition: service_started
|
||||||
migrator:
|
migrator:
|
||||||
condition: service_completed_successfully
|
condition: service_completed_successfully
|
||||||
|
rabbitmq:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
rabbitmq:
|
||||||
|
image: rabbitmq:4-management-alpine
|
||||||
|
environment:
|
||||||
|
RABBITMQ_DEFAULT_USER: ${RABBITMQ_USER:-guest}
|
||||||
|
RABBITMQ_DEFAULT_PASS: ${RABBITMQ_PASSWORD:-guest}
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "rabbitmq-diagnostics", "ping"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
Reference in New Issue
Block a user