Retructured solution
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
using LiteCharms.Features.Extensions;
|
||||
using static LiteCharms.Features.Extensions.Timezones;
|
||||
|
||||
namespace LiteCharms.Features.Abstractions;
|
||||
|
||||
public abstract class EventBase
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.CreateVersion7();
|
||||
|
||||
public DateTimeOffset EnqueueAt { get; set; } = SouthAfricanTimeZone.UtcNow();
|
||||
|
||||
public string CorrelationId { get; set; } = Guid.CreateVersion7().ToString();
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace LiteCharms.Features.Abstractions;
|
||||
|
||||
public interface IEvent : INotification
|
||||
{
|
||||
Guid Id { get; set; }
|
||||
|
||||
string Name { get; set; }
|
||||
|
||||
DateTimeOffset EnqueueAt { get; set; }
|
||||
|
||||
string CorrelationId { get; set; }
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
using LiteCharms.Features.Email.Commands;
|
||||
using LiteCharms.Models.Configuraton.Email;
|
||||
|
||||
namespace LiteCharms.Features.Email.Commands.Handlers;
|
||||
|
||||
public class SendEmailCommandHandler(IOptions<SmtpSettings> smtpOptions) : IRequestHandler<SendEmailCommand, Result>
|
||||
{
|
||||
public async ValueTask<Result> Handle(SendEmailCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var settings = smtpOptions.Value;
|
||||
|
||||
if(settings == null)
|
||||
return Result.Fail(new Error("SMTP settings are not configured."));
|
||||
|
||||
if(settings.Credentials == null)
|
||||
return Result.Fail(new Error("SMTP credentials are not configured."));
|
||||
|
||||
if(string.IsNullOrWhiteSpace(settings?.Credentials.Username) || string.IsNullOrWhiteSpace(settings.Credentials.Password))
|
||||
return Result.Fail(new Error("SMTP credentials are incomplete."));
|
||||
|
||||
if(string.IsNullOrWhiteSpace(settings.Host) || settings.Port == 0)
|
||||
return Result.Fail(new Error("SMTP host and port must be configured."));
|
||||
|
||||
var message = new MimeMessage();
|
||||
message.From.Add(new MailboxAddress(request.SenderName, request.From!));
|
||||
message.To.Add(new MailboxAddress(request.RecipientName, request.To!));
|
||||
message.Subject = request.Subject!;
|
||||
|
||||
var bodyBuilder = new BodyBuilder();
|
||||
|
||||
if(request.Attachment?.Length > 0 && !string.IsNullOrEmpty(request.AttachmentFileName))
|
||||
bodyBuilder.Attachments.Add(request.AttachmentFileName!, request.Attachment!, cancellationToken);
|
||||
|
||||
if (!request.IsHtml) bodyBuilder.TextBody = request.Message;
|
||||
if (request.IsHtml) bodyBuilder.HtmlBody = request.Message;
|
||||
|
||||
message.Body = bodyBuilder.ToMessageBody();
|
||||
|
||||
using var client = new SmtpClient();
|
||||
|
||||
await client.ConnectAsync(settings.Host!, settings.Port, settings.UseSsl, cancellationToken);
|
||||
await client.AuthenticateAsync(settings.Credentials!.Username!, settings.Credentials.Password!, cancellationToken);
|
||||
|
||||
var response = await client.SendAsync(message, cancellationToken);
|
||||
|
||||
bool emailSent = response.Contains("OK", StringComparison.InvariantCultureIgnoreCase);
|
||||
|
||||
await client.DisconnectAsync(true, cancellationToken);
|
||||
|
||||
return emailSent
|
||||
? Result.Ok()
|
||||
: Result.Fail(new Error("Failed to send email. SMTP response: " + response));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Result.Fail(new Error(ex.Message).CausedBy(ex));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
namespace LiteCharms.Features.Email.Commands;
|
||||
|
||||
public class SendEmailCommand : IRequest<Result>
|
||||
{
|
||||
public string? From { get; set; }
|
||||
|
||||
public string? SenderName { get; set; }
|
||||
|
||||
public string? To { get; set; }
|
||||
|
||||
public string? RecipientName { get; set; }
|
||||
|
||||
public string? Subject { get; set; }
|
||||
|
||||
public string? Message { get; set; }
|
||||
|
||||
public bool IsHtml { get; set; }
|
||||
|
||||
public Stream? Attachment { get; set; }
|
||||
|
||||
public string? AttachmentFileName { get; set; }
|
||||
|
||||
private SendEmailCommand(string from, string senderName, string to, string recipientName, string subject, string message, bool isHtml = false, Stream? attachment = null, string? attachmentFileName = null)
|
||||
{
|
||||
From = from;
|
||||
To = to;
|
||||
Subject = subject;
|
||||
Message = message;
|
||||
IsHtml = isHtml;
|
||||
Attachment = attachment;
|
||||
AttachmentFileName = attachmentFileName;
|
||||
SenderName = senderName;
|
||||
RecipientName = recipientName;
|
||||
}
|
||||
|
||||
public static SendEmailCommand Create(string from, string senderName, string to, string recipientName, string subject, string message, bool isHtml = false, Stream? attachment = null, string? attachmentFileName = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(from))
|
||||
throw new ArgumentException("From address is required.");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(senderName))
|
||||
throw new ArgumentException("Sender name is required.");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(senderName) && senderName?.Length > 255)
|
||||
throw new ArgumentException("Sender name cannot exceed 255 characters.");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(to))
|
||||
throw new ArgumentException("To address is required.");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(recipientName))
|
||||
throw new ArgumentException("Recipient name is required.");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(recipientName) && recipientName?.Length > 255)
|
||||
throw new ArgumentException("Recipient name cannot exceed 255 characters.");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(subject))
|
||||
throw new ArgumentException("Subject is required.");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(subject) && subject?.Length > 2048)
|
||||
throw new ArgumentException("Subject cannot exceed 2048 characters.");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(message))
|
||||
throw new ArgumentException("Message is required.");
|
||||
|
||||
if (message.Length > 10485760)
|
||||
throw new ArgumentException("Message cannot exceed 10 MB.");
|
||||
|
||||
if (attachment != null && string.IsNullOrWhiteSpace(attachmentFileName))
|
||||
throw new ArgumentException("Attachment file name must be provided when an attachment is included.");
|
||||
|
||||
if (attachment is not null && attachment.Length > 10485760)
|
||||
throw new ArgumentException("Attachment cannot exceed 10 MB.");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(attachmentFileName) && attachmentFileName.Length > 255)
|
||||
throw new ArgumentException("Attachment file name cannot exceed 255 characters.");
|
||||
|
||||
return new(from, senderName!, to, recipientName!, subject!, message, isHtml, attachment, attachmentFileName);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace LiteCharms.Features.Email.Configuration;
|
||||
|
||||
public class Account
|
||||
{
|
||||
public string? Username { get; set; }
|
||||
|
||||
public string? Password { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace LiteCharms.Features.Email.Configuration;
|
||||
|
||||
public class SmtpSettings
|
||||
{
|
||||
public Account? Credentials { get; set; }
|
||||
|
||||
public int Port { get; set; }
|
||||
|
||||
public string? Host { get; set; }
|
||||
|
||||
public bool UseSsl { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
using LiteCharms.Features.Email.Configuration;
|
||||
using LiteCharms.Features.Email.Extensions;
|
||||
using LiteCharms.Features.Email.Models;
|
||||
using LiteCharms.Features.Shop;
|
||||
|
||||
namespace LiteCharms.Features.Email;
|
||||
|
||||
public class EmailService(IOptions<SmtpSettings> options) : IEmailService
|
||||
{
|
||||
private readonly SmtpSettings settings = options.Value;
|
||||
private readonly SmtpClient client = new();
|
||||
private readonly int sendMaxCount = 10;
|
||||
private int sendCount = 0;
|
||||
|
||||
public EmailStatuses Status { get; private set; } = EmailStatuses.Disconnected;
|
||||
|
||||
public async Task<Result<Response>> SendEmailAsync(Message message, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var activity = EmailTelemetry.Source.StartActivity("Email Send");
|
||||
activity?.SetTag("email.recipient", message.Recipient?.Address);
|
||||
|
||||
try
|
||||
{
|
||||
if (Status != EmailStatuses.Connected)
|
||||
{
|
||||
activity?.SetStatus(ActivityStatusCode.Error, "Disconnected");
|
||||
|
||||
return Result.Fail<Response>("Smtp service is disconnected.");
|
||||
}
|
||||
|
||||
var email = new MimeMessage();
|
||||
email.From.Add(new MailboxAddress(message.Sender!.Name, message.Sender.Address!));
|
||||
email.To.Add(new MailboxAddress(message.Recipient!.Name, message.Recipient!.Address!));
|
||||
email.Subject = message.Subject!;
|
||||
|
||||
var bodyBuilder = new BodyBuilder();
|
||||
|
||||
foreach (var attachment in message.Body?.Attachments!)
|
||||
bodyBuilder.Attachments.Add(attachment.Name!, attachment.FileStream!, cancellationToken);
|
||||
|
||||
if (!message.Body.Properties.IsHtml) bodyBuilder.TextBody = message.Body.Message;
|
||||
if (message.Body.Properties.IsHtml) bodyBuilder.HtmlBody = message.Body.Message;
|
||||
|
||||
email.Body = bodyBuilder.ToMessageBody();
|
||||
|
||||
var response = await client.SendAsync(email, cancellationToken);
|
||||
|
||||
bool emailSent = response.Contains("OK", StringComparison.InvariantCultureIgnoreCase);
|
||||
|
||||
message.Dispose();
|
||||
|
||||
Interlocked.Increment(ref sendCount);
|
||||
|
||||
if (sendCount % sendMaxCount == 0)
|
||||
{
|
||||
using var delayActivity = EmailTelemetry.Source.StartActivity("Rate Limit Pause");
|
||||
|
||||
sendCount = 0;
|
||||
|
||||
await Task.Delay(1000, cancellationToken);
|
||||
}
|
||||
|
||||
if (emailSent)
|
||||
{
|
||||
EmailTelemetry.EmailsSent.Add(1, new TagList { { "host", settings.Host } });
|
||||
|
||||
return Result.Ok(Response.Create(EmailStatuses.Success));
|
||||
}
|
||||
|
||||
await DisconnectAsync(cancellationToken);
|
||||
|
||||
if (response.Contains("421"))
|
||||
{
|
||||
Status = EmailStatuses.TooManyConnections;
|
||||
|
||||
return Result.Fail<Response>(response);
|
||||
}
|
||||
|
||||
if (response.Contains("451"))
|
||||
{
|
||||
Status = EmailStatuses.ConnectionAborted;
|
||||
|
||||
return Result.Fail<Response>(response);
|
||||
}
|
||||
|
||||
EmailTelemetry.EmailsFailed.Add(1, new TagList { { "error_message", response } });
|
||||
|
||||
Status = EmailStatuses.Disconnected;
|
||||
|
||||
return Result.Fail<Response>("General error, disconnected");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
activity?.SetStatus(ActivityStatusCode.Error, ex.Message);
|
||||
activity?.AddException(ex);
|
||||
|
||||
EmailTelemetry.EmailsFailed.Add(1, new TagList { { "exception", ex.GetType().Name } });
|
||||
|
||||
return Result.Fail(new Error(ex.Message).CausedBy(ex));
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<Result<Response>> ConnectAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var activity = EmailTelemetry.Source.StartActivity("Email Connect");
|
||||
activity?.SetTag("email.smtp.connect", settings.Host);
|
||||
|
||||
try
|
||||
{
|
||||
if (Status is EmailStatuses.Connected) return Result.Ok(Response.Create(Status));
|
||||
|
||||
await client.ConnectAsync(settings.Host!, settings.Port, settings.UseSsl, cancellationToken);
|
||||
await client.AuthenticateAsync(settings.Credentials!.Username!, settings.Credentials.Password!, cancellationToken);
|
||||
|
||||
Status = EmailStatuses.Connected;
|
||||
|
||||
activity?.SetStatus(ActivityStatusCode.Ok, "Connected");
|
||||
|
||||
return Result.Ok(Response.Create(Status));
|
||||
}
|
||||
catch (MailKit.ProtocolException ex)
|
||||
{
|
||||
activity?.SetStatus(ActivityStatusCode.Error, ex.Message);
|
||||
activity?.AddException(ex);
|
||||
|
||||
EmailTelemetry.EmailsFailed.Add(1, new TagList { { "exception", ex.GetType().Name } });
|
||||
|
||||
Status = EmailStatuses.ProtocolError;
|
||||
|
||||
return Result.Fail<Response>(new Error(ex.Message).CausedBy(ex));
|
||||
}
|
||||
catch (Exception ex) when (ex is MailKit.Security.SslHandshakeException || ex is MailKit.Security.AuthenticationException)
|
||||
{
|
||||
activity?.SetStatus(ActivityStatusCode.Error, ex.Message);
|
||||
activity?.AddException(ex);
|
||||
|
||||
EmailTelemetry.EmailsFailed.Add(1, new TagList { { "exception", ex.GetType().Name } });
|
||||
|
||||
Status = EmailStatuses.AuthenticationError;
|
||||
|
||||
return Result.Fail<Response>(new Error(ex.Message).CausedBy(ex));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
activity?.SetStatus(ActivityStatusCode.Error, ex.Message);
|
||||
activity?.AddException(ex);
|
||||
|
||||
EmailTelemetry.EmailsFailed.Add(1, new TagList { { "exception", ex.GetType().Name } });
|
||||
|
||||
Status = EmailStatuses.GeneralError;
|
||||
|
||||
return Result.Fail<Response>(new Error(ex.Message).CausedBy(ex));
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<Result> DisconnectAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var activity = EmailTelemetry.Source.StartActivity("Email Disconnect");
|
||||
activity?.SetTag("email.smtp.disconnect", settings.Host);
|
||||
|
||||
try
|
||||
{
|
||||
if (Status is EmailStatuses.Disconnected) return Result.Ok();
|
||||
|
||||
await client.DisconnectAsync(true, cancellationToken);
|
||||
|
||||
activity?.SetStatus(ActivityStatusCode.Ok, "Disconnected");
|
||||
|
||||
Status = EmailStatuses.Disconnected;
|
||||
|
||||
return Result.Ok();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
activity?.SetStatus(ActivityStatusCode.Error, ex.Message);
|
||||
activity?.AddException(ex);
|
||||
|
||||
EmailTelemetry.EmailsFailed.Add(1, new TagList { { "exception", ex.GetType().Name } });
|
||||
|
||||
Status = EmailStatuses.GeneralError;
|
||||
|
||||
return Result.Fail(new Error(ex.Message).CausedBy(ex));
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
client.Dispose();
|
||||
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,22 @@
|
||||
using LiteCharms.Features.Notifications.Commands;
|
||||
using static LiteCharms.Abstractions.Constants;
|
||||
using LiteCharms.Features.Shop;
|
||||
using static LiteCharms.Features.Email.Extensions.Constants;
|
||||
|
||||
namespace LiteCharms.Features.Email.Events.Handlers;
|
||||
|
||||
// TODO: Inject the INotificationService
|
||||
public class SendShopEmailEnquiryEventHandler(ISender mediator) :
|
||||
INotificationHandler<SendShopEmailEnquiryEvent>
|
||||
{
|
||||
public async ValueTask Handle(SendShopEmailEnquiryEvent notification, CancellationToken cancellationToken)
|
||||
{
|
||||
var command = CreateNotificationCommand.Create(Models.NotificationDirection.Outgoing, notification.SenderName!,
|
||||
notification.SenderAddress!, notification.Subject!, notification.Message!, Models.NotificationPlatforms.Email,
|
||||
// TODO: Refactor this to use the NotificationService
|
||||
var command = CreateNotification.Create(NotificationDirection.Outgoing, notification.SenderName!,
|
||||
notification.SenderAddress!, notification.Subject!, notification.Message!, NotificationPlatforms.Email,
|
||||
notification.Priority, ShopEmailFromName, ShopEmailFromAddress, Guid.CreateVersion7().ToString(),
|
||||
Models.CorrelationIdTypes.None, isInternal: true, isHtml: false);
|
||||
CorrelationIdTypes.None, isInternal: true, isHtml: false);
|
||||
|
||||
// TODO: Remove, deprecated
|
||||
await mediator.Send(command, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
using LiteCharms.Abstractions;
|
||||
using LiteCharms.Models;
|
||||
using LiteCharms.Features.Abstractions;
|
||||
using LiteCharms.Features.Shop;
|
||||
|
||||
namespace LiteCharms.Features.Email.Events;
|
||||
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace LiteCharms.Features.Email.Extensions;
|
||||
|
||||
public static class Constants
|
||||
{
|
||||
public const string ShopSchedulerName = "shop";
|
||||
public const string ShopEmailFromName = "Khongisa Shop";
|
||||
public const string ShopEmailFromAddress = "shop@litecharms.co.za";
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace LiteCharms.Features.Email.Extensions;
|
||||
|
||||
public static class EmailTelemetry
|
||||
{
|
||||
public static readonly ActivitySource Source = new("LiteCharms.EmailService");
|
||||
public static readonly Meter Meter = new("LiteCharms.EmailService");
|
||||
public static readonly Counter<long> EmailsSent = Meter.CreateCounter<long>("emails_sent_total", "count", "Total successful emails sent");
|
||||
public static readonly Counter<long> EmailsFailed = Meter.CreateCounter<long>("emails_failed_total", "count", "Total failed email attempts");
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using LiteCharms.Features.Email.Configuration;
|
||||
|
||||
namespace LiteCharms.Features.Email.Extensions;
|
||||
|
||||
public static class Setup
|
||||
{
|
||||
public static IServiceCollection AddEmailServices(this IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
services.Configure<SmtpSettings>(configuration.GetSection("Email"));
|
||||
|
||||
services.AddSingleton<IEmailService, EmailService>();
|
||||
|
||||
services.AddOpenTelemetry()
|
||||
.WithTracing(tracing => tracing.AddSource("LiteCharms.EmailService"))
|
||||
.WithMetrics(metrics => metrics.AddMeter("LiteCharms.EmailService"));
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
using LiteCharms.Features.Email.Models;
|
||||
using LiteCharms.Features.Shop;
|
||||
|
||||
namespace LiteCharms.Features.Email;
|
||||
|
||||
public interface IEmailService : IDisposable
|
||||
{
|
||||
EmailStatuses Status { get; }
|
||||
|
||||
Task<Result<Response>> SendEmailAsync(Message message, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<Result<Response>> ConnectAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
Task<Result> DisconnectAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace LiteCharms.Features.Email.Models;
|
||||
|
||||
public class Attachment
|
||||
{
|
||||
public string? Name { get; set; }
|
||||
|
||||
public Stream? FileStream { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
namespace LiteCharms.Features.Email.Models;
|
||||
|
||||
public class Body : IDisposable
|
||||
{
|
||||
public string? Message { get; set; }
|
||||
|
||||
public ReadOnlyCollection<Attachment>? Attachments { get; set; }
|
||||
|
||||
public BodyProperties Properties { get; set; } = new();
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Attachments is null) return;
|
||||
|
||||
foreach (var attachment in Attachments!)
|
||||
{
|
||||
if (attachment is not null)
|
||||
{
|
||||
attachment.FileStream!.Close();
|
||||
attachment.FileStream!.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace LiteCharms.Features.Email.Models;
|
||||
|
||||
public class BodyProperties
|
||||
{
|
||||
public bool IsHtml { get; set; }
|
||||
|
||||
public bool HasAttachments { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace LiteCharms.Features.Email.Models;
|
||||
|
||||
public sealed class EmailEnquiry
|
||||
{
|
||||
[Required]
|
||||
[MinLength(2)]
|
||||
[MaxLength(255)]
|
||||
[Display(Name = "Full Name")]
|
||||
public string? FullName { get; set; }
|
||||
|
||||
[Required]
|
||||
[EmailAddress]
|
||||
[MinLength(5)]
|
||||
[MaxLength(255)]
|
||||
[Display(Name = "Email Address")]
|
||||
public string? EmailAddress { get; set; }
|
||||
|
||||
[Required]
|
||||
[MinLength(2)]
|
||||
[MaxLength(255)]
|
||||
[Display(Name = "Subject")]
|
||||
public string? EmailSubject { get; set; }
|
||||
|
||||
[Required]
|
||||
[MinLength(2)]
|
||||
[MaxLength(2000)]
|
||||
[Display(Name = "Message")]
|
||||
public string? Message { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
namespace LiteCharms.Features.Email.Models;
|
||||
|
||||
public class Message : IDisposable
|
||||
{
|
||||
public Party? Sender { get; set; }
|
||||
|
||||
public Party? Recipient { get; set; }
|
||||
|
||||
public string? Subject { get; set; }
|
||||
|
||||
public Body? Body { get; set; }
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Body?.Dispose();
|
||||
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace LiteCharms.Features.Email.Models;
|
||||
|
||||
public class Party
|
||||
{
|
||||
public string? Name { get; set; }
|
||||
|
||||
public string? Address { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using LiteCharms.Features.Shop;
|
||||
|
||||
namespace LiteCharms.Features.Email.Models;
|
||||
|
||||
public class Response
|
||||
{
|
||||
public int Code { get; set; }
|
||||
|
||||
public string? Error { get; set; }
|
||||
|
||||
public EmailStatuses Status { get; set; }
|
||||
|
||||
private Response(EmailStatuses status, int code = 0, string? error = null)
|
||||
{
|
||||
Status = status;
|
||||
Code = code;
|
||||
Error = error;
|
||||
}
|
||||
|
||||
public static Response Create(EmailStatuses status, int code = 0, string? error = null) =>
|
||||
new(status, code, error);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
namespace LiteCharms.Features.Extensions;
|
||||
|
||||
public static class Constants
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
using LiteCharms.Models;
|
||||
|
||||
namespace LiteCharms.Extensions;
|
||||
|
||||
public static class EntityModeMappers
|
||||
{
|
||||
public static ShoppingCartPackage ToModel(this Entities.ShoppingCartPackage entity) =>
|
||||
new()
|
||||
{
|
||||
Id = entity.Id,
|
||||
CreatedAt = entity.CreatedAt,
|
||||
PackageId = entity.PackageId,
|
||||
ShoppingCartId = entity.ShoppingCartId
|
||||
};
|
||||
|
||||
public static PackageItem ToModel(this Entities.PackageItem entity) =>
|
||||
new()
|
||||
{
|
||||
Id = entity.Id,
|
||||
Active = entity.Active,
|
||||
CreatedAt = entity.CreatedAt,
|
||||
PackageId = entity.PackageId,
|
||||
ProductPriceId = entity.ProductPriceId
|
||||
};
|
||||
|
||||
public static Package ToModel(this Entities.Package entity) =>
|
||||
new()
|
||||
{
|
||||
Id = entity.Id,
|
||||
CreatedAt = entity.CreatedAt,
|
||||
Active = entity.Active,
|
||||
Description = entity.Description,
|
||||
Name = entity.Name,
|
||||
UpdatedAt = entity.UpdatedAt
|
||||
};
|
||||
|
||||
public static ShoppingCartItem ToModel(this Entities.ShoppingCartItem entity) =>
|
||||
new()
|
||||
{
|
||||
Id = entity.Id,
|
||||
CreatedAt = entity.CreatedAt,
|
||||
UpdatedAt = entity.UpdatedAt,
|
||||
ProductPriceId = entity.ProductPriceId,
|
||||
Quantity = entity.Quantity,
|
||||
ShoppingCartId = entity.ShoppingCartId
|
||||
};
|
||||
|
||||
public static ShoppingCart ToModel(this Entities.ShoppingCart entity) =>
|
||||
new()
|
||||
{
|
||||
Id = entity.Id,
|
||||
CreatedAt = entity.CreatedAt,
|
||||
UpdatedAt = entity.UpdatedAt,
|
||||
CustomerId = entity.CustomerId,
|
||||
OrderId = entity.OrderId,
|
||||
QuoteId = entity.QuoteId
|
||||
};
|
||||
|
||||
public static Quote ToModel(this Entities.Quote entity) =>
|
||||
new()
|
||||
{
|
||||
Id = entity.Id,
|
||||
CreatedAt = entity.CreatedAt,
|
||||
UpdatedAt = entity.UpdatedAt,
|
||||
CustomerId = entity.CustomerId,
|
||||
ExpiredAt = entity.ExpiredAt,
|
||||
Reason = entity.Reason,
|
||||
ShoppingCartId = entity.ShoppingCartId,
|
||||
Status = entity.Status
|
||||
};
|
||||
|
||||
public static Notification ToModel(this Entities.Notification entity) =>
|
||||
new()
|
||||
{
|
||||
Id = entity.Id,
|
||||
CreatedAt = entity.CreatedAt,
|
||||
Message = entity.Message,
|
||||
Direction = entity.Direction,
|
||||
CorrelationId = entity.CorrelationId,
|
||||
CorrelationIdType = entity.CorrelationIdType,
|
||||
IsInternal = entity.IsInternal,
|
||||
Sender = entity.Sender,
|
||||
Platform = entity.Platform,
|
||||
Recipient = entity.Recipient,
|
||||
Subject = entity.Subject,
|
||||
Processed = entity.Processed,
|
||||
SenderName = entity.SenderName,
|
||||
RecipientAddress = entity.RecipientAddress,
|
||||
Priority = entity.Priority,
|
||||
UpdatedAt = entity?.UpdatedAt,
|
||||
IsHtml = entity!.IsHtml,
|
||||
HasError = entity.HasError,
|
||||
Errors = entity.Errors
|
||||
};
|
||||
|
||||
public static Customer ToModel(this Entities.Customer entity) =>
|
||||
new()
|
||||
{
|
||||
Id = entity.Id,
|
||||
CreatedAt = entity.CreatedAt,
|
||||
UpdatedAt = entity.UpdatedAt,
|
||||
Active = entity.Active,
|
||||
Address = entity.Address,
|
||||
City = entity.City,
|
||||
Company = entity.Company,
|
||||
Country = entity.Country,
|
||||
Discord = entity.Discord,
|
||||
Email = entity.Email,
|
||||
LastName = entity.LastName,
|
||||
LinkedIn = entity.LinkedIn,
|
||||
Name = entity.Name,
|
||||
Phone = entity.Phone,
|
||||
PostalCode = entity.PostalCode,
|
||||
Region = entity.Region,
|
||||
Slack = entity.Slack,
|
||||
Tax = entity.Tax,
|
||||
Website = entity.Website,
|
||||
Whatsapp = entity.Whatsapp
|
||||
};
|
||||
|
||||
public static Lead ToModel(this Entities.Lead entity) =>
|
||||
new()
|
||||
{
|
||||
Id = entity.Id,
|
||||
CreatedAt = entity.CreatedAt,
|
||||
UpdatedAt = entity.UpdatedAt,
|
||||
AdGroupId = entity.AdGroupId,
|
||||
AdName = entity.AdName,
|
||||
AppClickId = entity.AppClickId,
|
||||
AttributionHash = entity.AttributionHash,
|
||||
CampaignId = entity.CampaignId,
|
||||
ClickLocation = entity.ClickLocation,
|
||||
CustomerId = entity.CustomerId,
|
||||
FeedItemId = entity.FeedItemId,
|
||||
Source = entity.Source,
|
||||
ClickId = entity.ClickId,
|
||||
TargetId = entity.TargetId,
|
||||
WebClickId = entity.WebClickId,
|
||||
Status = entity.Status
|
||||
};
|
||||
|
||||
public static Order ToModel(this Entities.Order entity) =>
|
||||
new()
|
||||
{
|
||||
Id = entity.Id,
|
||||
CreatedAt = entity.CreatedAt,
|
||||
UpdatedAt = entity.UpdatedAt,
|
||||
CustomerId = entity.CustomerId,
|
||||
Notes = entity.Notes,
|
||||
RefundId = entity.RefundId,
|
||||
QuoteId = entity.QuoteId,
|
||||
Status = entity.Status,
|
||||
ShoppingCartId = entity.ShoppingCartId,
|
||||
DepositRequired = entity.DepositRequired,
|
||||
Requirements = entity.Requirements,
|
||||
Terms = entity.Terms
|
||||
};
|
||||
|
||||
public static OrderRefund ToModel(this Entities.OrderRefund entity) =>
|
||||
new()
|
||||
{
|
||||
Id = entity.Id,
|
||||
CreatedAt = entity.CreatedAt,
|
||||
OrderId = entity.OrderId,
|
||||
Reason = entity.Reason,
|
||||
Amount = entity.Amount
|
||||
};
|
||||
|
||||
public static Product ToModel(this Entities.Product entity) =>
|
||||
new()
|
||||
{
|
||||
Id = entity.Id,
|
||||
Name = entity.Name,
|
||||
Description = entity.Description,
|
||||
Active = entity.Active
|
||||
};
|
||||
|
||||
public static ProductPrice ToModel(this Entities.ProductPrice entity) =>
|
||||
new()
|
||||
{
|
||||
Id = entity.Id,
|
||||
ProductId = entity.ProductId,
|
||||
Price = entity.Price,
|
||||
Active = entity.Active,
|
||||
CreatedAt = entity.CreatedAt,
|
||||
Discount = entity.Discount,
|
||||
UpdatedAt = entity.UpdatedAt
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace LiteCharms.Features.Extensions;
|
||||
|
||||
public static class Hash
|
||||
{
|
||||
public static Func<string?, string?> GenerateSha256HashString = (input) =>
|
||||
Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(input!)));
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
using LiteCharms.Infrastructure.HealthChecks;
|
||||
|
||||
namespace LiteCharms.Extensions;
|
||||
|
||||
public static class HealthChecks
|
||||
{
|
||||
public static IServiceCollection AddQuartzHealtchCheck(this IServiceCollection services)
|
||||
{
|
||||
services.AddHealthChecks().AddCheck<QuartzHealthCheck>("Quartz");
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
public static IServiceCollection AddPostgresHealtchCheck(this IServiceCollection services)
|
||||
{
|
||||
services.AddHealthChecks().AddCheck<PostgresHealthCheck>("PostgreSQL");
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
public static IServiceCollection AddHealthChecksSupport(this IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
services.AddHealthChecks()
|
||||
.AddCheck("Self", () => HealthCheckResult.Healthy());
|
||||
|
||||
//services.AddHealthChecksUI(setup =>
|
||||
//{
|
||||
// setup.AddHealthCheckEndpoint("Lead Generator", $"{configuration["ASPNETCORE_URLS"]}/health");
|
||||
// setup.SetEvaluationTimeInSeconds(15);
|
||||
//}).AddInMemoryStorage(databaseName: "healthuidb");
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
namespace LiteCharms.Extensions;
|
||||
|
||||
public static class Monitoring
|
||||
{
|
||||
public static WebApplicationBuilder AddMonitoring(this WebApplicationBuilder builder)
|
||||
{
|
||||
var serviceName = builder.Configuration.GetValue<string>("Monitoring:ServiceName") ?? "LiteCharms";
|
||||
var endpoint = builder.Configuration.GetValue<string>("Monitoring:Address")!;
|
||||
var apiKey = builder.Configuration.GetValue<string>("Monitoring:ApiKey");
|
||||
|
||||
var resourceBuilder = ResourceBuilder.CreateDefault()
|
||||
.AddService(serviceName);
|
||||
|
||||
var otlpHeaders = !string.IsNullOrEmpty(apiKey) ? $"x-otlp-api-key={apiKey}" : null;
|
||||
|
||||
builder.Logging.AddOpenTelemetry(logging =>
|
||||
{
|
||||
logging.SetResourceBuilder(resourceBuilder);
|
||||
logging.AddOtlpExporter(opt =>
|
||||
{
|
||||
opt.Endpoint = new Uri(endpoint);
|
||||
opt.Protocol = OtlpExportProtocol.Grpc;
|
||||
opt.Headers = otlpHeaders;
|
||||
});
|
||||
});
|
||||
|
||||
builder.Services.AddOpenTelemetry()
|
||||
.WithTracing(tracing => tracing
|
||||
.SetResourceBuilder(resourceBuilder)
|
||||
.AddAspNetCoreInstrumentation()
|
||||
.AddHttpClientInstrumentation()
|
||||
.AddOtlpExporter(opt =>
|
||||
{
|
||||
opt.Endpoint = new Uri(endpoint);
|
||||
opt.Protocol = OtlpExportProtocol.Grpc;
|
||||
opt.Headers = otlpHeaders;
|
||||
}))
|
||||
.WithMetrics(metrics => metrics
|
||||
.SetResourceBuilder(resourceBuilder)
|
||||
.AddMeter(serviceName)
|
||||
.AddAspNetCoreInstrumentation()
|
||||
.AddRuntimeInstrumentation()
|
||||
.AddOtlpExporter(opt =>
|
||||
{
|
||||
opt.Endpoint = new Uri(endpoint);
|
||||
opt.Protocol = OtlpExportProtocol.Grpc;
|
||||
opt.Headers = otlpHeaders;
|
||||
}));
|
||||
|
||||
return builder;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using LiteCharms.Infrastructure.Database;
|
||||
|
||||
namespace LiteCharms.Extensions;
|
||||
|
||||
public static class Postgres
|
||||
{
|
||||
public static IServiceCollection AddShopDatabase(this IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
services.AddPooledDbContextFactory<ShopDbContext>(options =>
|
||||
options.UseNpgsql(configuration.GetConnectionString("PostgresShop")));
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
using LiteCharms.Abstractions;
|
||||
using LiteCharms.Infrastructure.Quartz;
|
||||
|
||||
namespace LiteCharms.Extensions;
|
||||
|
||||
public static class Quartz
|
||||
{
|
||||
private const string databaseConfigName = "PostgresScheduler";
|
||||
|
||||
public static IServiceCollection AddQuartzSchedulerClient(this IServiceCollection services, string schedulerName, IConfiguration configuration)
|
||||
{
|
||||
var connectionString = configuration.GetConnectionString(databaseConfigName);
|
||||
|
||||
services.ConfigureCommon();
|
||||
|
||||
services.AddQuartz(config =>
|
||||
{
|
||||
config.SchedulerName = schedulerName;
|
||||
config.SchedulerId = "AUTO";
|
||||
|
||||
config.UseSimpleTypeLoader();
|
||||
config.UseDefaultThreadPool(options => options.MaxConcurrency = 0);
|
||||
config.UseTimeZoneConverter();
|
||||
|
||||
config.UsePersistentStore(storage =>
|
||||
{
|
||||
storage.PerformSchemaValidation = false;
|
||||
|
||||
storage.UseSystemTextJsonSerializer();
|
||||
storage.SetProperty("quartz.jobStore.clustered", "true");
|
||||
storage.SetProperty("quartz.jobStore.tablePrefix", "qrtz_");
|
||||
|
||||
storage.UsePostgres(connectionString!);
|
||||
storage.UseClustering(cluster =>
|
||||
{
|
||||
cluster.CheckinInterval = TimeSpan.FromSeconds(30);
|
||||
cluster.CheckinMisfireThreshold = TimeSpan.FromSeconds(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
public static IServiceCollection AddQuartzScheduler(this IServiceCollection services, string schedulerName, IConfiguration configuration)
|
||||
{
|
||||
var connectionString = configuration.GetConnectionString(databaseConfigName);
|
||||
|
||||
services.ConfigureCommon();
|
||||
|
||||
services.AddQuartz(config =>
|
||||
{
|
||||
config.SchedulerName = schedulerName;
|
||||
config.SchedulerId = "AUTO";
|
||||
config.InterruptJobsOnShutdown = true;
|
||||
config.InterruptJobsOnShutdownWithWait = true;
|
||||
config.MaxBatchSize = 5;
|
||||
|
||||
config.UseSimpleTypeLoader();
|
||||
config.UseDefaultThreadPool(options => options.MaxConcurrency = 1);
|
||||
config.UseTimeZoneConverter();
|
||||
|
||||
config.UsePersistentStore(storage =>
|
||||
{
|
||||
storage.PerformSchemaValidation = false;
|
||||
|
||||
storage.UseSystemTextJsonSerializer();
|
||||
storage.SetProperty("quartz.jobStore.clustered", "true");
|
||||
storage.SetProperty("quartz.jobStore.tablePrefix", "qrtz_");
|
||||
|
||||
storage.UsePostgres(connectionString!);
|
||||
storage.UseClustering(cluster =>
|
||||
{
|
||||
cluster.CheckinInterval = TimeSpan.FromSeconds(30);
|
||||
cluster.CheckinMisfireThreshold = TimeSpan.FromSeconds(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
private static IServiceCollection ConfigureCommon(this IServiceCollection services)
|
||||
{
|
||||
services.Configure<QuartzOptions>(options =>
|
||||
{
|
||||
options.Scheduling.IgnoreDuplicates = true;
|
||||
options.Scheduling.OverWriteExistingData = true;
|
||||
options["quartz.plugin.jobHistory.type"] = "Quartz.Plugin.History.LoggingJobHistoryPlugin, Quartz.Plugins";
|
||||
options["quartz.plugin.triggerHistory.type"] = "Quartz.Plugin.History.LoggingTriggerHistoryPlugin, Quartz.Plugins";
|
||||
});
|
||||
|
||||
services.AddTransient<RetryJobListener>();
|
||||
services.AddTransient<IJobOrchestrator, JobOrchestrator>();
|
||||
services.AddQuartzHostedService(options => options.WaitForJobsToComplete = true);
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
using LiteCharms.Abstractions;
|
||||
using LiteCharms.Infrastructure.ServiceBus;
|
||||
using LiteCharms.Infrastructure.ServiceBus.Exchanges;
|
||||
using LiteCharms.Infrastructure.ServiceBus.Queues;
|
||||
|
||||
namespace LiteCharms.Extensions;
|
||||
|
||||
public static class ServiceBus
|
||||
{
|
||||
public static IServiceCollection AddGeneralServiceBus(this IServiceCollection services) => services
|
||||
.AddSingleton<GeneralQueue>()
|
||||
.AddHostedService<GeneralExchange>()
|
||||
.AddKeyedSingleton<IEventBus, GeneralServiceBus>(Constants.GeneralServiceBus)
|
||||
.AddMemoryCache();
|
||||
|
||||
public static IServiceCollection AddEmailServiceBus(this IServiceCollection services) => services
|
||||
.AddSingleton<EmailQueue>()
|
||||
.AddHostedService<EmailExchange>()
|
||||
.AddKeyedTransient<IEventBus, EmailServiceBus>(Constants.EmailServiceBus)
|
||||
.AddMemoryCache();
|
||||
|
||||
public static IServiceCollection AddSalesServiceBus(this IServiceCollection services) => services
|
||||
.AddSingleton<SalesQueue>()
|
||||
.AddHostedService<SalesExchange>()
|
||||
.AddKeyedSingleton<IEventBus, SalesServiceBus>(Constants.SalesServiceBus)
|
||||
.AddMemoryCache();
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
namespace LiteCharms.Features.Extensions;
|
||||
|
||||
public static class Timezones
|
||||
{
|
||||
public static TimeZoneInfo SouthAfricanTimeZone => TimeZoneInfo.FindSystemTimeZoneById("South Africa Standard Time");
|
||||
|
||||
public static string? LocaliseDateTime(this DateTime dateTime, TimeSpan offset) => offset.Hours > 0
|
||||
? $"{dateTime:yyyy-MM-ddTHH:mm:ss.fff}+{offset.Hours:00}:{offset.Minutes:00}"
|
||||
: $"{dateTime:yyyy-MM-ddTHH:mm:ss.fff}{offset.Hours:00}:{offset.Minutes:00}";
|
||||
|
||||
public static string? LocaliseDateTimeOffset(this DateTimeOffset dateTime, TimeSpan offset) => LocaliseDateTime(dateTime.DateTime, offset);
|
||||
|
||||
public static DateTimeOffset ToDateTimeWithTimeZone(this DateTime source, TimeZoneInfo? timezone = null)
|
||||
{
|
||||
DateTime sourceDateAdjusted = source.Kind != DateTimeKind.Utc
|
||||
? new(source.Ticks, DateTimeKind.Utc)
|
||||
: source;
|
||||
|
||||
var localised = timezone is null
|
||||
? new DateTimeOffset(sourceDateAdjusted.Ticks, SouthAfricanTimeZone.BaseUtcOffset).LocaliseDateTimeOffset(SouthAfricanTimeZone.BaseUtcOffset)
|
||||
: new DateTimeOffset(sourceDateAdjusted.Ticks, timezone!.BaseUtcOffset).LocaliseDateTimeOffset(timezone.BaseUtcOffset);
|
||||
|
||||
return DateTimeOffset.Parse(localised!);
|
||||
}
|
||||
|
||||
public static DateTimeOffset UtcNow(this TimeZoneInfo timezone) => ToDateTimeWithTimeZone(DateTime.Now, timezone);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
namespace LiteCharms.Features.HealthChecks;
|
||||
|
||||
public class PostgresHealthCheck(IConfiguration configuration) : IHealthCheck
|
||||
{
|
||||
private readonly string connectionString = configuration.GetConnectionString("PostgresShop")!;
|
||||
|
||||
public async Task<HealthCheckResult> CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var dataSource = NpgsqlDataSource.Create(connectionString);
|
||||
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken);
|
||||
|
||||
await using var command = connection.CreateCommand();
|
||||
command.CommandText = "SELECT 1";
|
||||
|
||||
await command.ExecuteScalarAsync(cancellationToken);
|
||||
|
||||
return HealthCheckResult.Healthy("PostgreSQL is responsive.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return HealthCheckResult.Unhealthy("PostgreSQL is unreachable.", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
namespace LiteCharms.Features.HealthChecks;
|
||||
|
||||
public class QuartzHealthCheck(ISchedulerFactory schedulerFactory) : IHealthCheck
|
||||
{
|
||||
public async Task<HealthCheckResult> CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var scheduler = await schedulerFactory.GetScheduler(cancellationToken);
|
||||
|
||||
if (!scheduler.IsStarted)
|
||||
return HealthCheckResult.Unhealthy("Quartz scheduler is not running");
|
||||
|
||||
await scheduler.CheckExists(new JobKey(Guid.NewGuid().ToString()), cancellationToken);
|
||||
|
||||
return HealthCheckResult.Healthy("Quartz scheduler is ready");
|
||||
}
|
||||
catch (SchedulerException)
|
||||
{
|
||||
return HealthCheckResult.Unhealthy("Quartz scheduler cannot connect to the store");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,86 @@
|
||||
<None Include="..\icon.png" Pack="true" PackagePath="\" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Quartz Scheduler-->
|
||||
<ItemGroup>
|
||||
<PackageReference Include="OpenTelemetry" Version="1.15.3" />
|
||||
<PackageReference Include="Quartz" Version="3.18.1" />
|
||||
<PackageReference Include="Quartz.Plugins" Version="3.18.1" />
|
||||
<PackageReference Include="Quartz.Plugins.TimeZoneConverter" Version="3.18.1" />
|
||||
<PackageReference Include="Quartz.Serialization.SystemTextJson" Version="3.18.1" />
|
||||
<PackageReference Include="Quartz.AspNetCore" Version="3.18.1" />
|
||||
<PackageReference Include="Quartz.Extensions.DependencyInjection" Version="3.18.1" />
|
||||
|
||||
<!-- Global Usings -->
|
||||
<Using Include="Quartz" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Configuration -->
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration" Version="10.0.8" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.8" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="10.0.8" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="10.0.8" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" Version="10.0.8" />
|
||||
|
||||
<!-- Global Usings -->
|
||||
<Using Include="Microsoft.Extensions.Configuration" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Health Checks -->
|
||||
<ItemGroup>
|
||||
<PackageReference Include="AspNetCore.HealthChecks.UI.Client" Version="9.0.0" />
|
||||
<PackageReference Include="AspNetCore.HealthChecks.UI" Version="9.0.0" />
|
||||
<PackageReference Include="AspNetCore.HealthChecks.UI.Core" Version="9.0.0" />
|
||||
<PackageReference Include="AspNetCore.HealthChecks.UI.Data" Version="9.0.0" />
|
||||
<PackageReference Include="AspNetCore.HealthChecks.UI.InMemory.Storage" Version="9.0.0" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.8" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.8" />
|
||||
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks" Version="10.0.8" />
|
||||
|
||||
<!-- Global Usings -->
|
||||
<Using Include="Microsoft.Extensions.Diagnostics.HealthChecks" />
|
||||
<Using Include="Microsoft.AspNetCore.Diagnostics.HealthChecks" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Open Telemetry -->
|
||||
<ItemGroup>
|
||||
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.15.3" />
|
||||
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.15.3" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.15.2" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.15.1" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.15.1" />
|
||||
<PackageReference Include="OpenTelemetry.Exporter.Console" Version="1.15.3" />
|
||||
|
||||
<!-- Global Usings -->
|
||||
<Using Include="OpenTelemetry.Resources" />
|
||||
<Using Include="OpenTelemetry.Exporter" />
|
||||
<Using Include="OpenTelemetry.Logs" />
|
||||
<Using Include="OpenTelemetry.Metrics" />
|
||||
<Using Include="OpenTelemetry.Trace" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Database -->
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.8" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.8">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.8" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.8">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.1" />
|
||||
|
||||
<!-- Global Usings -->
|
||||
<Using Include="Npgsql" />
|
||||
<Using Include="Microsoft.EntityFrameworkCore" />
|
||||
<Using Include="Microsoft.EntityFrameworkCore.Design" />
|
||||
<Using Include="Microsoft.EntityFrameworkCore.Metadata.Builders" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Email -->
|
||||
<ItemGroup>
|
||||
<PackageReference Include="MailKit" Version="4.16.0" />
|
||||
@@ -50,16 +130,17 @@
|
||||
|
||||
<!-- Shared Usings -->
|
||||
<ItemGroup>
|
||||
<Using Include="Microsoft.Extensions.Hosting" />
|
||||
<Using Include="System.Text" />
|
||||
<Using Include="System.Security.Cryptography" />
|
||||
<Using Include="Microsoft.EntityFrameworkCore" />
|
||||
<Using Include="System.Text.Json" />
|
||||
<Using Include="System.Threading.Channels" />
|
||||
<Using Include="System.Collections.ObjectModel" />
|
||||
<Using Include="System.Diagnostics" />
|
||||
<Using Include="System.Diagnostics.Metrics" />
|
||||
<Using Include="Microsoft.Extensions.DependencyInjection" />
|
||||
<Using Include="System.Security.Cryptography" />
|
||||
<Using Include="Microsoft.Extensions.Options" />
|
||||
<Using Include="Microsoft.Extensions.Logging" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\LiteCharms.Extensions\LiteCharms.Extensions.csproj" />
|
||||
<ProjectReference Include="..\LiteCharms.Infrastructure\LiteCharms.Infrastructure.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
namespace LiteCharms.Features.Mediator;
|
||||
|
||||
public sealed class LoggingPipelineBehavior<TRequest, TResponse>(ILogger<LoggingPipelineBehavior<TRequest, TResponse>> logger) :
|
||||
IPipelineBehavior<TRequest, TResponse?>
|
||||
where TRequest : IRequest<TResponse>
|
||||
where TResponse : ResultBase, new()
|
||||
{
|
||||
public async ValueTask<TResponse?> Handle(TRequest message, MessageHandlerDelegate<TRequest, TResponse?> next, CancellationToken cancellationToken)
|
||||
{
|
||||
TResponse? response = await next(message, cancellationToken);
|
||||
|
||||
if (response is null)
|
||||
logger.LogCritical("{Request} {TypeName} was returned as null", typeof(TRequest).Name, typeof(TRequest).Name);
|
||||
|
||||
if(response?.IsFailed == true || response?.Errors?.Any() == true)
|
||||
{
|
||||
foreach (var error in response.Errors)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(error.Message))
|
||||
logger.LogWarning("{Request} {Error}", typeof(TRequest).Name, error.Message);
|
||||
|
||||
if (error?.Reasons?.Count > 0)
|
||||
error.Reasons.ForEach(r => logger.LogError("{Request} {Reason}", typeof(TRequest).Name, r.ToString()));
|
||||
}
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace LiteCharms.Features.Mediator;
|
||||
|
||||
public static class MediatorTelemetry
|
||||
{
|
||||
public const string ServiceName = "LiteCharms.Mediator";
|
||||
|
||||
public static readonly ActivitySource Source = new(ServiceName);
|
||||
public static readonly Meter Meter = new(ServiceName);
|
||||
|
||||
public static readonly Counter<long> RequestCounter = Meter.CreateCounter<long>("mediator_requests_total");
|
||||
public static readonly Histogram<double> RequestDuration = Meter.CreateHistogram<double>("mediator_request_duration_ms");
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
namespace LiteCharms.Features.Mediator;
|
||||
|
||||
public sealed class TelemetryPipelineBehavior<TRequest, TResponse> :
|
||||
IPipelineBehavior<TRequest, TResponse?>
|
||||
where TRequest : IRequest<TResponse>
|
||||
where TResponse : ResultBase, new()
|
||||
{
|
||||
public async ValueTask<TResponse?> Handle(TRequest message, MessageHandlerDelegate<TRequest, TResponse?> next, CancellationToken cancellationToken)
|
||||
{
|
||||
var requestName = typeof(TRequest).Name;
|
||||
|
||||
using var activity = MediatorTelemetry.Source.StartActivity(requestName);
|
||||
|
||||
activity?.SetTag("mediator.request_type", typeof(TRequest).FullName);
|
||||
|
||||
var stopWatch = Stopwatch.StartNew();
|
||||
var status = "Success";
|
||||
|
||||
try
|
||||
{
|
||||
TResponse? response = await next(message, cancellationToken);
|
||||
|
||||
if (response is null)
|
||||
{
|
||||
status = "NullResponse";
|
||||
activity?.SetStatus(ActivityStatusCode.Error, "Response was null");
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
if (response.IsFailed)
|
||||
{
|
||||
status = "Failed";
|
||||
activity?.SetStatus(ActivityStatusCode.Error, "Request failed");
|
||||
|
||||
var firstError = response.Errors.FirstOrDefault()?.Message ?? "Unknown Error";
|
||||
activity?.SetTag("error.message", firstError);
|
||||
|
||||
foreach (var error in response.Errors)
|
||||
activity?.AddEvent(new ActivityEvent("Result Error", tags: new() { { "message", error.Message } }));
|
||||
}
|
||||
else
|
||||
activity?.SetStatus(ActivityStatusCode.Ok);
|
||||
|
||||
return response;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
status = "Exception";
|
||||
activity?.SetStatus(ActivityStatusCode.Error, ex.Message);
|
||||
|
||||
activity?.AddException(ex);
|
||||
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
stopWatch.Stop();
|
||||
|
||||
var tags = new TagList { { "request", requestName }, { "status", status } };
|
||||
|
||||
MediatorTelemetry.RequestCounter.Add(1, tags);
|
||||
MediatorTelemetry.RequestDuration.Record(stopWatch.Elapsed.TotalMilliseconds, tags);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
using LiteCharms.Features.Abstractions;
|
||||
|
||||
namespace LiteCharms.Features.Quartz.Abstractions;
|
||||
|
||||
public interface IJobOrchestrator
|
||||
{
|
||||
Task SendAsync<TNotification>(TNotification notification, CancellationToken cancellationToken = default)
|
||||
where TNotification : IEvent;
|
||||
|
||||
Task ScheduleAsync<TNotification>(TNotification notification, string cronExpression, CancellationToken cancellationToken = default)
|
||||
where TNotification : IEvent;
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
using LiteCharms.Features.Abstractions;
|
||||
using LiteCharms.Features.Quartz.Abstractions;
|
||||
using static LiteCharms.Features.Extensions.Timezones;
|
||||
|
||||
namespace LiteCharms.Features.Quartz;
|
||||
|
||||
public class JobOrchestrator(ISchedulerFactory schedulerFactory) : IJobOrchestrator
|
||||
{
|
||||
public async Task SendAsync<TNotification>(TNotification notification, CancellationToken cancellationToken = default)
|
||||
where TNotification : IEvent
|
||||
{
|
||||
var chainedJobGroup = "onetime-jobs";
|
||||
|
||||
var scheduler = await schedulerFactory.GetScheduler(cancellationToken);
|
||||
var jobKey = new JobKey($"{notification.Name.ToLower()}-{notification.CorrelationId.ToLower()}", chainedJobGroup);
|
||||
var triggerKey = new TriggerKey($"{jobKey.Name}-trigger", chainedJobGroup);
|
||||
|
||||
var job = JobBuilder.Create<MediatorJob<TNotification>>()
|
||||
.WithIdentity(jobKey)
|
||||
.WithDescription($"Correlation ID: {notification.CorrelationId}")
|
||||
.UsingJobData(new JobDataMap { ["Payload"] = JsonSerializer.Serialize(notification) })
|
||||
.DisallowConcurrentExecution()
|
||||
.Build();
|
||||
|
||||
var trigger = global::Quartz.TriggerBuilder.Create()
|
||||
.WithIdentity(triggerKey)
|
||||
.StartNow()
|
||||
.Build();
|
||||
|
||||
await scheduler.ScheduleJob(job, new List<ITrigger> { trigger }.AsReadOnly(), replace: true, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task ScheduleAsync<TNotification>(TNotification notification, string cronExpression, CancellationToken cancellationToken = default)
|
||||
where TNotification : IEvent
|
||||
{
|
||||
var chainedJobGroup = "scheduled-jobs";
|
||||
|
||||
var scheduler = await schedulerFactory.GetScheduler(cancellationToken);
|
||||
var jobKey = new JobKey($"{notification.Name.ToLower()}", chainedJobGroup);
|
||||
var triggerKey = new TriggerKey($"{jobKey.Name}-trigger", chainedJobGroup);
|
||||
|
||||
var job = JobBuilder.Create<MediatorJob<TNotification>>()
|
||||
.WithIdentity(jobKey)
|
||||
.WithDescription($"Correlation ID: {notification.CorrelationId}")
|
||||
.UsingJobData(new JobDataMap { ["Payload"] = JsonSerializer.Serialize(notification) })
|
||||
.DisallowConcurrentExecution()
|
||||
.StoreDurably()
|
||||
.Build();
|
||||
|
||||
var now = SouthAfricanTimeZone.UtcNow();
|
||||
|
||||
var trigger = global::Quartz.TriggerBuilder.Create()
|
||||
.WithIdentity(triggerKey)
|
||||
.WithDescription($"Scheduled via Main Job at {now:g}")
|
||||
.WithCronSchedule(cronExpression, cron => cron.InTimeZone(SouthAfricanTimeZone)
|
||||
.WithMisfireHandlingInstructionFireAndProceed())
|
||||
.StartAt(now)
|
||||
.Build();
|
||||
|
||||
await scheduler.AddJob(job, replace: true, cancellationToken);
|
||||
|
||||
if (await scheduler.CheckExists(triggerKey, cancellationToken))
|
||||
await scheduler.RescheduleJob(triggerKey, trigger, cancellationToken);
|
||||
else
|
||||
await scheduler.ScheduleJob(job, new List<ITrigger> { trigger }.AsReadOnly(), replace: true, cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using LiteCharms.Features.Abstractions;
|
||||
|
||||
namespace LiteCharms.Features.Quartz;
|
||||
|
||||
[DisallowConcurrentExecution]
|
||||
public class MediatorJob<TNotification>(IMediator mediator) : IJob where TNotification : IEvent
|
||||
{
|
||||
public async Task Execute(IJobExecutionContext context)
|
||||
{
|
||||
var data = context.MergedJobDataMap["Payload"] as string;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(data)) return;
|
||||
|
||||
var notification = JsonSerializer.Deserialize<TNotification>(data);
|
||||
|
||||
if(notification is null) return;
|
||||
|
||||
if(notification is TNotification)
|
||||
await mediator.Publish(notification, context.CancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace LiteCharms.Features.Quartz;
|
||||
|
||||
public class RetryJobListener : IJobListener
|
||||
{
|
||||
public string Name => "RetryJobListener";
|
||||
|
||||
public int RetryCount { get; set; } = 3;
|
||||
|
||||
public Task JobExecutionVetoed(IJobExecutionContext context, CancellationToken cancellationToken = default) => Task.CompletedTask;
|
||||
|
||||
public Task JobToBeExecuted(IJobExecutionContext context, CancellationToken cancellationToken = default) => Task.CompletedTask;
|
||||
|
||||
public async Task JobWasExecuted(IJobExecutionContext context, JobExecutionException? jobException, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (jobException is not null && context.RefireCount < RetryCount)
|
||||
jobException.RefireImmediately = true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
using LiteCharms.Features.Abstractions;
|
||||
|
||||
namespace LiteCharms.Features.ServiceBus.Abstractions;
|
||||
|
||||
public abstract class EventBusQueueBase
|
||||
{
|
||||
protected readonly Channel<IEvent> channel = Channel.CreateBounded<IEvent>(Constants.QueueBounds);
|
||||
|
||||
public ChannelWriter<IEvent> Outgoing => channel.Writer;
|
||||
|
||||
public ChannelReader<IEvent> Incoming => channel.Reader;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
using LiteCharms.Features.Abstractions;
|
||||
|
||||
namespace LiteCharms.Features.ServiceBus.Abstractions;
|
||||
|
||||
public interface IEventBus
|
||||
{
|
||||
Task<Result> PublishAsync<TEvent>(TEvent notification, CancellationToken cancellationToken = default)
|
||||
where TEvent : class, IEvent;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
using LiteCharms.Features.Abstractions;
|
||||
|
||||
namespace LiteCharms.Features.ServiceBus.Abstractions;
|
||||
|
||||
public interface IEventBusQueue
|
||||
{
|
||||
ChannelWriter<IEvent> Outgoing { get; }
|
||||
|
||||
ChannelReader<IEvent> Incoming { get; }
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace LiteCharms.Features.ServiceBus;
|
||||
|
||||
public static class Constants
|
||||
{
|
||||
public const int QueueBounds = 100000;
|
||||
|
||||
public const string EmailServiceBus = nameof(EmailServiceBus);
|
||||
public const string GeneralServiceBus = nameof(GeneralServiceBus);
|
||||
public const string SalesServiceBus = nameof(SalesServiceBus);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
using LiteCharms.Features.Abstractions;
|
||||
using LiteCharms.Features.ServiceBus.Abstractions;
|
||||
using LiteCharms.Features.ServiceBus.Queues;
|
||||
|
||||
namespace LiteCharms.Features.ServiceBus;
|
||||
|
||||
public class EmailServiceBus(EmailQueue messages) : IEventBus
|
||||
{
|
||||
public async Task<Result> PublishAsync<TEvent>(TEvent notification, CancellationToken cancellationToken = default)
|
||||
where TEvent : class, IEvent
|
||||
{
|
||||
try
|
||||
{
|
||||
await messages.Outgoing.WriteAsync(notification, cancellationToken);
|
||||
|
||||
return Result.Ok();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Result.Fail(new Error(ex.Message).CausedBy(ex));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using LiteCharms.Features.Abstractions;
|
||||
using LiteCharms.Features.ServiceBus.Queues;
|
||||
|
||||
namespace LiteCharms.Features.ServiceBus.Exchanges;
|
||||
|
||||
public class EmailExchange(EmailQueue messages, ILogger<EmailExchange> logger, IPublisher mediator) : BackgroundService
|
||||
{
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
await foreach(IEvent? message in messages.Incoming.ReadAllAsync(stoppingToken))
|
||||
{
|
||||
try
|
||||
{
|
||||
switch (message.Name)
|
||||
{
|
||||
case "SendShopEmailEnquiryEvent":
|
||||
await mediator.Publish(message, stoppingToken);
|
||||
break;
|
||||
case "ProcessEmailNotificationsEvent":
|
||||
await mediator.Publish(message, stoppingToken);
|
||||
break;
|
||||
default:
|
||||
logger.LogWarning("Unsupported email event {Event}", message.Name);
|
||||
break;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, ex.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
using LiteCharms.Features.ServiceBus.Queues;
|
||||
|
||||
namespace LiteCharms.Features.ServiceBus.Exchanges;
|
||||
|
||||
public class GeneralExchange(GeneralQueue messages) : BackgroundService
|
||||
{
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
if (messages.Incoming.CanCount)
|
||||
await Task.Delay(1000, stoppingToken);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
using LiteCharms.Features.ServiceBus.Queues;
|
||||
|
||||
namespace LiteCharms.Features.ServiceBus.Exchanges;
|
||||
|
||||
public class SalesExchange(SalesQueue messages) : BackgroundService
|
||||
{
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
if (messages.Incoming.CanCount)
|
||||
await Task.Delay(1000, stoppingToken);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
using LiteCharms.Features.Abstractions;
|
||||
using LiteCharms.Features.ServiceBus.Abstractions;
|
||||
using LiteCharms.Features.ServiceBus.Queues;
|
||||
|
||||
namespace LiteCharms.Features.ServiceBus;
|
||||
|
||||
public class GeneralServiceBus(GeneralQueue messages) : IEventBus
|
||||
{
|
||||
public async Task<Result> PublishAsync<TEvent>(TEvent notification, CancellationToken cancellationToken = default)
|
||||
where TEvent : class, IEvent
|
||||
{
|
||||
try
|
||||
{
|
||||
await messages.Outgoing.WriteAsync(notification, cancellationToken);
|
||||
|
||||
return Result.Ok();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Result.Fail(new Error(ex.Message).CausedBy(ex));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
using LiteCharms.Features.ServiceBus.Abstractions;
|
||||
|
||||
namespace LiteCharms.Features.ServiceBus.Queues;
|
||||
|
||||
public class EmailQueue : EventBusQueueBase, IEventBusQueue;
|
||||
@@ -0,0 +1,5 @@
|
||||
using LiteCharms.Features.ServiceBus.Abstractions;
|
||||
|
||||
namespace LiteCharms.Features.ServiceBus.Queues;
|
||||
|
||||
public class GeneralQueue : EventBusQueueBase, IEventBusQueue;
|
||||
@@ -0,0 +1,5 @@
|
||||
using LiteCharms.Features.ServiceBus.Abstractions;
|
||||
|
||||
namespace LiteCharms.Features.ServiceBus.Queues;
|
||||
|
||||
public class SalesQueue : EventBusQueueBase, IEventBusQueue;
|
||||
@@ -0,0 +1,23 @@
|
||||
using LiteCharms.Features.Abstractions;
|
||||
using LiteCharms.Features.ServiceBus.Abstractions;
|
||||
using LiteCharms.Features.ServiceBus.Queues;
|
||||
|
||||
namespace LiteCharms.Features.ServiceBus;
|
||||
|
||||
public class SalesServiceBus(SalesQueue messages) : IEventBus
|
||||
{
|
||||
public async Task<Result> PublishAsync<TEvent>(TEvent notification, CancellationToken cancellationToken = default)
|
||||
where TEvent : class, IEvent
|
||||
{
|
||||
try
|
||||
{
|
||||
await messages.Outgoing.WriteAsync(notification, cancellationToken);
|
||||
|
||||
return Result.Ok();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Result.Fail(new Error(ex.Message).CausedBy(ex));
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
using LiteCharms.Infrastructure.Database;
|
||||
using LiteCharms.Features.Shop.Postgres;
|
||||
|
||||
namespace LiteCharms.Features.CartPackages.Commands.Handlers;
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
using LiteCharms.Infrastructure.Database;
|
||||
using LiteCharms.Features.Shop.Postgres;
|
||||
|
||||
namespace LiteCharms.Features.CartPackages.Commands.Handlers;
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
using LiteCharms.Infrastructure.Database;
|
||||
using LiteCharms.Features.Shop.Postgres;
|
||||
|
||||
namespace LiteCharms.Features.CartPackages.Commands.Handlers;
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
using LiteCharms.Infrastructure.Database;
|
||||
using LiteCharms.Features.Shop.Postgres;
|
||||
|
||||
namespace LiteCharms.Features.CartPackages.Commands.Handlers;
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
using LiteCharms.Infrastructure.Database;
|
||||
using LiteCharms.Features.Shop.Postgres;
|
||||
|
||||
namespace LiteCharms.Features.CartPackages.Commands.Handlers;
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
using LiteCharms.Infrastructure.Database;
|
||||
using LiteCharms.Features.Shop.Postgres;
|
||||
|
||||
namespace LiteCharms.Features.CartPackages.Commands.Handlers;
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace LiteCharms.Features.Shop.CartPackages.Entities;
|
||||
|
||||
[EntityTypeConfiguration<PackageConfirguration, Package>]
|
||||
public class Package : Models.Package
|
||||
{
|
||||
public virtual ICollection<PackageItem>? PackageItems { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace LiteCharms.Features.Shop.CartPackages.Entities;
|
||||
|
||||
public class PackageConfirguration : IEntityTypeConfiguration<Package>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<Package> builder)
|
||||
{
|
||||
builder.ToTable(nameof(Package));
|
||||
|
||||
builder.HasKey(f => f.Id);
|
||||
builder.Property(f => f.CreatedAt).IsRequired().ValueGeneratedOnAdd().HasDefaultValueSql("now()");
|
||||
builder.Property(f => f.UpdatedAt).IsRequired(false);
|
||||
builder.Property(f => f.Name).IsRequired();
|
||||
builder.Property(f => f.Summary).IsRequired().HasMaxLength(512);
|
||||
builder.Property(f => f.Description).IsRequired().HasMaxLength(2048);
|
||||
builder.Property(f => f.ImageUrl).IsRequired(false).HasMaxLength(2048);
|
||||
builder.Property(f => f.Active);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
using LiteCharms.Features.Shop.Products.Entities;
|
||||
|
||||
namespace LiteCharms.Features.Shop.CartPackages.Entities;
|
||||
|
||||
[EntityTypeConfiguration<PackageItemConfiguration, PackageItem>]
|
||||
public class PackageItem : Models.PackageItem
|
||||
{
|
||||
public virtual Package? Package { get; set; }
|
||||
|
||||
public virtual ProductPrice? ProductPrice { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
namespace LiteCharms.Features.Shop.CartPackages.Entities;
|
||||
|
||||
public class PackageItemConfiguration : IEntityTypeConfiguration<PackageItem>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<PackageItem> builder)
|
||||
{
|
||||
builder.ToTable(nameof(PackageItem));
|
||||
|
||||
builder.HasKey(f => f.Id);
|
||||
builder.Property(f => f.CreatedAt).IsRequired().ValueGeneratedOnAdd().HasDefaultValueSql("now()");
|
||||
builder.Property(f => f.PackageId).IsRequired();
|
||||
builder.Property(f => f.ProductPriceId).IsRequired();
|
||||
builder.Property(f => f.Active);
|
||||
|
||||
builder.HasOne(f => f.Package)
|
||||
.WithMany(f => f.PackageItems)
|
||||
.HasForeignKey(pi => pi.PackageId)
|
||||
.IsRequired()
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasOne(f => f.ProductPrice)
|
||||
.WithMany()
|
||||
.HasForeignKey(pi => pi.ProductPriceId)
|
||||
.IsRequired()
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
namespace LiteCharms.Features.Shop.CartPackages.Models;
|
||||
|
||||
public class Package
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
|
||||
public DateTimeOffset? UpdatedAt { get; set; }
|
||||
|
||||
public string? Name { get; set; }
|
||||
|
||||
public string? Summary { get; set; }
|
||||
|
||||
public string? Description { get; set; }
|
||||
|
||||
public string? ImageUrl { get; set; }
|
||||
|
||||
public bool Active { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace LiteCharms.Features.Shop.CartPackages.Models;
|
||||
|
||||
public class PackageItem
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
|
||||
public Guid PackageId { get; set; }
|
||||
|
||||
public Guid ProductPriceId { get; set; }
|
||||
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
|
||||
public bool Active { get; set; }
|
||||
}
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
using LiteCharms.Models;
|
||||
using LiteCharms.Features.Shop.CartPackages.Models;
|
||||
|
||||
namespace LiteCharms.Features.CartPackages.Queries;
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
using LiteCharms.Models;
|
||||
using LiteCharms.Features.Shop.CartPackages.Models;
|
||||
|
||||
namespace LiteCharms.Features.CartPackages.Queries;
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
using LiteCharms.Models;
|
||||
using LiteCharms.Features.Shop.CartPackages.Models;
|
||||
|
||||
namespace LiteCharms.Features.CartPackages.Queries;
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
using LiteCharms.Extensions;
|
||||
using LiteCharms.Infrastructure.Database;
|
||||
using LiteCharms.Models;
|
||||
using LiteCharms.Features.Shop.CartPackages.Models;
|
||||
using LiteCharms.Features.Shop.Postgres;
|
||||
|
||||
namespace LiteCharms.Features.CartPackages.Queries.Handlers;
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
using LiteCharms.Extensions;
|
||||
using LiteCharms.Infrastructure.Database;
|
||||
using LiteCharms.Models;
|
||||
using LiteCharms.Features.Shop.CartPackages.Models;
|
||||
using LiteCharms.Features.Shop.Postgres;
|
||||
|
||||
namespace LiteCharms.Features.CartPackages.Queries.Handlers;
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
using LiteCharms.Extensions;
|
||||
using LiteCharms.Infrastructure.Database;
|
||||
using LiteCharms.Models;
|
||||
using LiteCharms.Features.Shop.CartPackages.Models;
|
||||
using LiteCharms.Features.Shop.Postgres;
|
||||
|
||||
namespace LiteCharms.Features.CartPackages.Queries.Handlers;
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
using LiteCharms.Infrastructure.Database;
|
||||
using LiteCharms.Features.Shop.Postgres;
|
||||
|
||||
namespace LiteCharms.Features.Customers.Commands.Handlers;
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
using LiteCharms.Infrastructure.Database;
|
||||
using LiteCharms.Features.Shop.Postgres;
|
||||
|
||||
namespace LiteCharms.Features.Customers.Commands.Handlers;
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
using LiteCharms.Features.Shop.Leads.Entities;
|
||||
using LiteCharms.Features.Shop.Orders.Entities;
|
||||
using LiteCharms.Features.Shop.Quotes.Entities;
|
||||
using LiteCharms.Features.Shop.ShoppingCarts.Entities;
|
||||
|
||||
namespace LiteCharms.Features.Shop.Customers.Entities;
|
||||
|
||||
[EntityTypeConfiguration<CustomerConfiguration, Customer>]
|
||||
public class Customer : Models.Customer
|
||||
{
|
||||
public virtual ICollection<Lead>? Leads { get; set; }
|
||||
|
||||
public virtual ICollection<Order>? Orders { get; set; }
|
||||
|
||||
public virtual ICollection<Quote>? Quotes { get; set; }
|
||||
|
||||
public virtual ICollection<ShoppingCart>? ShoppingCarts { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
namespace LiteCharms.Features.Shop.Customers.Entities;
|
||||
|
||||
public class CustomerConfiguration : IEntityTypeConfiguration<Customer>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<Customer> builder)
|
||||
{
|
||||
builder.ToTable(nameof(Customer));
|
||||
|
||||
builder.HasKey(f => f.Id);
|
||||
builder.Property(f => f.CreatedAt).ValueGeneratedOnAdd().HasDefaultValueSql("now()");
|
||||
builder.Property(f => f.UpdatedAt).IsRequired(false);
|
||||
builder.Property(f => f.Company);
|
||||
builder.Property(f => f.Name).IsRequired();
|
||||
builder.Property(f => f.LastName).IsRequired();
|
||||
builder.Property(f => f.Email).IsRequired();
|
||||
builder.Property(f => f.Tax);
|
||||
builder.Property(f => f.Discord);
|
||||
builder.Property(f => f.Slack);
|
||||
builder.Property(f => f.LinkedIn);
|
||||
builder.Property(f => f.Whatsapp);
|
||||
builder.Property(f => f.Website);
|
||||
builder.Property(f => f.Phone);
|
||||
builder.Property(f => f.Address);
|
||||
builder.Property(f => f.City);
|
||||
builder.Property(f => f.Region);
|
||||
builder.Property(f => f.Country);
|
||||
builder.Property(f => f.PostalCode);
|
||||
builder.Property(f => f.Active).HasDefaultValue(true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
namespace LiteCharms.Features.Shop.Customers.Models;
|
||||
|
||||
public class Customer
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
|
||||
public DateTimeOffset? UpdatedAt { get; set; }
|
||||
|
||||
public string? Company { get; set; }
|
||||
|
||||
public string? Name { get; set; }
|
||||
|
||||
public string? LastName { get; set; }
|
||||
|
||||
public string? Tax { get; set; }
|
||||
|
||||
public string? Email { get; set; }
|
||||
|
||||
public string? Discord { get; set; }
|
||||
|
||||
public string? Slack { get; set; }
|
||||
|
||||
public string? LinkedIn { get; set; }
|
||||
|
||||
public string? Whatsapp { get; set; }
|
||||
|
||||
public string? Website { get; set; }
|
||||
|
||||
public string? Phone { get; set; }
|
||||
|
||||
public string? Address { get; set; }
|
||||
|
||||
public string? City { get; set; }
|
||||
|
||||
public string? Region { get; set; }
|
||||
|
||||
public string? Country { get; set; }
|
||||
|
||||
public string? PostalCode { get; set; }
|
||||
|
||||
public bool Active { get; set; }
|
||||
}
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
using LiteCharms.Models;
|
||||
using LiteCharms.Features.Shop.Customers.Models;
|
||||
|
||||
namespace LiteCharms.Features.Customers.Queries;
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
using LiteCharms.Models;
|
||||
using LiteCharms.Features.Shop.Customers.Models;
|
||||
|
||||
namespace LiteCharms.Features.Customers.Queries;
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
using LiteCharms.Extensions;
|
||||
using LiteCharms.Infrastructure.Database;
|
||||
using LiteCharms.Models;
|
||||
using LiteCharms.Features.Shop.Customers.Models;
|
||||
using LiteCharms.Features.Shop.Postgres;
|
||||
|
||||
namespace LiteCharms.Features.Customers.Queries.Handlers;
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
using LiteCharms.Extensions;
|
||||
using LiteCharms.Infrastructure.Database;
|
||||
using LiteCharms.Models;
|
||||
using LiteCharms.Features.Shop.Customers.Models;
|
||||
using LiteCharms.Features.Shop.Postgres;
|
||||
|
||||
namespace LiteCharms.Features.Customers.Queries.Handlers;
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
namespace LiteCharms.Features.Shop;
|
||||
|
||||
public enum EmailStatuses : int
|
||||
{
|
||||
GeneralError = 0,
|
||||
AuthenticationError = 1,
|
||||
ProtocolError = 2,
|
||||
Connected = 3,
|
||||
Disconnected = 4,
|
||||
TooManyConnections = 5,
|
||||
ConnectionAborted = 6,
|
||||
Success = 7
|
||||
}
|
||||
|
||||
public enum CorrelationIdTypes : int
|
||||
{
|
||||
None = 0,
|
||||
Email = 1,
|
||||
Discord = 2,
|
||||
Slack = 3,
|
||||
Whatsapp = 4,
|
||||
Customer = 5,
|
||||
Order = 6,
|
||||
Refund = 7,
|
||||
Lead = 8,
|
||||
Quote = 9,
|
||||
LinkedIn = 10
|
||||
}
|
||||
|
||||
public enum Priorities : int
|
||||
{
|
||||
Low = 0,
|
||||
Medium = 1,
|
||||
High = 2,
|
||||
}
|
||||
|
||||
public enum NotificationPlatforms : int
|
||||
{
|
||||
Email = 1,
|
||||
Discord = 2,
|
||||
Slack = 3,
|
||||
WhatsApp = 4,
|
||||
System = 5
|
||||
}
|
||||
|
||||
public enum QuoteStatus : int
|
||||
{
|
||||
Draft = 0,
|
||||
Sent = 1,
|
||||
Accepted = 2,
|
||||
Rejected = 3,
|
||||
Expired = 4
|
||||
}
|
||||
|
||||
public enum OrderStatus : int
|
||||
{
|
||||
Pending = 0,
|
||||
Completed = 1,
|
||||
Cancelled = 2,
|
||||
Failed = 3,
|
||||
Refunded = 4,
|
||||
Error = 5
|
||||
}
|
||||
|
||||
public enum LeadStatus : int
|
||||
{
|
||||
New = 0,
|
||||
Contacted = 1,
|
||||
Qualified = 2,
|
||||
Unqualified = 3,
|
||||
Converted = 4,
|
||||
Lost = 5
|
||||
}
|
||||
|
||||
public enum NotificationDirection : int
|
||||
{
|
||||
Incoming = 0,
|
||||
Outgoing = 1,
|
||||
Neutral = 2
|
||||
}
|
||||
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
using LiteCharms.Features.Utilities.Hash.Commands;
|
||||
using LiteCharms.Infrastructure.Database;
|
||||
using LiteCharms.Features.Shop.Postgres;
|
||||
using LiteCharms.Features.Utilities.Hash.Commands;
|
||||
|
||||
namespace LiteCharms.Features.Leads.Commands.Handlers;
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
using LiteCharms.Infrastructure.Database;
|
||||
using LiteCharms.Features.Shop.Postgres;
|
||||
|
||||
namespace LiteCharms.Features.Leads.Commands.Handlers;
|
||||
|
||||
+2
-1
@@ -1,4 +1,5 @@
|
||||
using LiteCharms.Models;
|
||||
using LiteCharms.Features.Shop;
|
||||
using LiteCharms.Models;
|
||||
|
||||
namespace LiteCharms.Features.Leads.Commands;
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
using LiteCharms.Features.Shop.Customers.Models;
|
||||
|
||||
namespace LiteCharms.Features.Shop.Leads.Entities;
|
||||
|
||||
[EntityTypeConfiguration<LeadConfiguration, Lead>]
|
||||
public class Lead : Models.Lead
|
||||
{
|
||||
public virtual Customer? Customer { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
namespace LiteCharms.Features.Shop.Leads.Entities;
|
||||
|
||||
public class LeadConfiguration : IEntityTypeConfiguration<Lead>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<Lead> builder)
|
||||
{
|
||||
builder.ToTable(nameof(Lead));
|
||||
|
||||
builder.HasKey(f => f.Id);
|
||||
builder.Property(f => f.CreatedAt).ValueGeneratedOnAdd().HasDefaultValueSql("now()");
|
||||
builder.Property(f => f.UpdatedAt).IsRequired(false);
|
||||
builder.Property(f => f.CustomerId).IsRequired(false);
|
||||
builder.Property(f => f.Source);
|
||||
builder.Property(f => f.ClickId);
|
||||
builder.Property(f => f.WebClickId);
|
||||
builder.Property(f => f.AppClickId);
|
||||
builder.Property(f => f.CampaignId);
|
||||
builder.Property(f => f.AdGroupId);
|
||||
builder.Property(f => f.AdName);
|
||||
builder.Property(f => f.TargetId);
|
||||
builder.Property(f => f.FeedItemId);
|
||||
builder.Property(f => f.ClickLocation);
|
||||
builder.Property(f => f.Status).IsRequired();
|
||||
builder.Property(f => f.AttributionHash).IsRequired(true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
namespace LiteCharms.Features.Shop.Leads.Models;
|
||||
|
||||
public class Lead
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
|
||||
public DateTimeOffset? UpdatedAt { get; set; }
|
||||
|
||||
public Guid? CustomerId { get; set; }
|
||||
|
||||
public string? Source { get; set; }
|
||||
|
||||
public string? ClickId { get; set; }
|
||||
|
||||
public string? WebClickId { get; set; }
|
||||
|
||||
public string? AppClickId { get; set; }
|
||||
|
||||
public long? CampaignId { get; set; }
|
||||
|
||||
public long? AdGroupId { get; set; }
|
||||
|
||||
public long? AdName { get; set; }
|
||||
|
||||
public long? TargetId { get; set; }
|
||||
|
||||
public long? FeedItemId { get; set; }
|
||||
|
||||
public string? ClickLocation { get; set; }
|
||||
|
||||
public string? AttributionHash { get; set; }
|
||||
|
||||
public LeadStatus Status { get; set; }
|
||||
}
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
using LiteCharms.Models;
|
||||
using LiteCharms.Features.Shop.Leads.Models;
|
||||
|
||||
namespace LiteCharms.Features.Leads.Queries;
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
using LiteCharms.Models;
|
||||
using LiteCharms.Features.Shop.Leads.Models;
|
||||
|
||||
namespace LiteCharms.Features.Leads.Queries;
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
using LiteCharms.Extensions;
|
||||
using LiteCharms.Infrastructure.Database;
|
||||
using LiteCharms.Models;
|
||||
using LiteCharms.Features.Shop.Leads.Models;
|
||||
using LiteCharms.Features.Shop.Postgres;
|
||||
|
||||
namespace LiteCharms.Features.Leads.Queries.Handlers;
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
using LiteCharms.Extensions;
|
||||
using LiteCharms.Infrastructure.Database;
|
||||
using LiteCharms.Models;
|
||||
using LiteCharms.Features.Shop.Leads.Models;
|
||||
using LiteCharms.Features.Shop.Postgres;
|
||||
|
||||
namespace LiteCharms.Features.Leads.Queries.Handlers;
|
||||
|
||||
+5
-4
@@ -1,8 +1,9 @@
|
||||
using LiteCharms.Models;
|
||||
using LiteCharms.Features.Shop;
|
||||
using LiteCharms.Models;
|
||||
|
||||
namespace LiteCharms.Features.Notifications.Commands;
|
||||
|
||||
public class CreateNotificationCommand : IRequest<Result<Guid>>
|
||||
public class CreateNotification : IRequest<Result<Guid>>
|
||||
{
|
||||
public NotificationDirection Direction { get; set; }
|
||||
|
||||
@@ -30,7 +31,7 @@ public class CreateNotificationCommand : IRequest<Result<Guid>>
|
||||
|
||||
public bool IsHtml { get; set; }
|
||||
|
||||
private CreateNotificationCommand(NotificationDirection direction, string sender, string senderAddress, string subject, string message, NotificationPlatforms platform, Priorities priority, string recipient, string recipientAddress, string correlationId, CorrelationIdTypes correlationIdType, bool isInternal, bool isHtml = false)
|
||||
private CreateNotification(NotificationDirection direction, string sender, string senderAddress, string subject, string message, NotificationPlatforms platform, Priorities priority, string recipient, string recipientAddress, string correlationId, CorrelationIdTypes correlationIdType, bool isInternal, bool isHtml = false)
|
||||
{
|
||||
Direction = direction;
|
||||
Sender = sender;
|
||||
@@ -47,7 +48,7 @@ public class CreateNotificationCommand : IRequest<Result<Guid>>
|
||||
IsHtml = isHtml;
|
||||
}
|
||||
|
||||
public static CreateNotificationCommand Create(NotificationDirection direction, string sender, string senderAddress, string subject, string message, NotificationPlatforms platform, Priorities priority, string recipient, string recipientAddress, string correlationId, CorrelationIdTypes correlationIdType, bool isInternal, bool isHtml = false)
|
||||
public static CreateNotification Create(NotificationDirection direction, string sender, string senderAddress, string subject, string message, NotificationPlatforms platform, Priorities priority, string recipient, string recipientAddress, string correlationId, CorrelationIdTypes correlationIdType, bool isInternal, bool isHtml = false)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(sender))
|
||||
throw new ArgumentException("Sender name is required.", nameof(sender));
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user