Completed initial database design
continuous-integration/drone/pr Build is passing

Sealed qualifying public classes
Migrated database changes
This commit is contained in:
Khwezi Mngoma
2026-05-27 09:12:04 +02:00
parent 70860efcfb
commit 902942eee6
86 changed files with 2883 additions and 140 deletions
@@ -7,7 +7,7 @@ public abstract class EventBase
{
public Guid Id { get; set; } = Guid.CreateVersion7();
public DateTimeOffset EnqueueAt { get; set; } = SouthAfricanTimeZone.UtcNow();
public DateTimeOffset EnqueueAt { get; set; } = (DateTimeOffset)SouthAfricanTimeZone.UtcNow();
public string CorrelationId { get; set; } = Guid.CreateVersion7().ToString();
}
@@ -1,6 +1,6 @@
namespace LiteCharms.Features.Email.Configuration;
public class Account
public sealed class Account
{
public string? Username { get; set; }
@@ -1,6 +1,6 @@
namespace LiteCharms.Features.Email.Configuration;
public class SmtpSettings
public sealed class SmtpSettings
{
public Account? Credentials { get; set; }
+47 -30
View File
@@ -4,7 +4,7 @@ using LiteCharms.Features.Email.Models;
namespace LiteCharms.Features.Email;
public class EmailService(IOptions<SmtpSettings> options) : IDisposable
public sealed class EmailService(IOptions<SmtpSettings> options) : IDisposable
{
private readonly SmtpSettings settings = options.Value;
private readonly SmtpClient client = new();
@@ -16,6 +16,7 @@ public class EmailService(IOptions<SmtpSettings> options) : IDisposable
public async ValueTask<Result<Response>> SendEmailAsync(Message message, CancellationToken cancellationToken = default)
{
using var activity = EmailTelemetry.Source.StartActivity("Email Send");
activity?.SetTag("email.recipient", message.Recipient?.Address);
try
@@ -27,21 +28,7 @@ public class EmailService(IOptions<SmtpSettings> options) : IDisposable
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();
if (message.Body!.Properties.HasAttachments)
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 email = ConstructEmail(message, cancellationToken);
var response = await client.SendAsync(email, cancellationToken);
@@ -69,21 +56,9 @@ public class EmailService(IOptions<SmtpSettings> options) : IDisposable
await DisconnectAsync(cancellationToken);
if (response.Contains("421"))
{
Status = EmailStatuses.TooManyConnections;
var failCheckResult = HandleNegativeResponse(response);
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 } });
if (failCheckResult.IsFailed) return failCheckResult;
Status = EmailStatuses.Disconnected;
@@ -100,6 +75,48 @@ public class EmailService(IOptions<SmtpSettings> options) : IDisposable
}
}
private static MimeMessage ConstructEmail(Message message, CancellationToken cancellationToken)
{
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();
if (message.Body!.Properties.HasAttachments)
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();
return email;
}
private Result<Response> HandleNegativeResponse(string response)
{
if (response.Contains("421", StringComparison.Ordinal))
{
Status = EmailStatuses.TooManyConnections;
return Result.Fail<Response>(response);
}
if (response.Contains("451", StringComparison.Ordinal))
{
Status = EmailStatuses.ConnectionAborted;
return Result.Fail<Response>(response);
}
EmailTelemetry.EmailsFailed.Add(1, new TagList { { "error_message", response } });
return Result.Fail<Response>(response);
}
public async ValueTask<Result<Response>> ConnectAsync(CancellationToken cancellationToken = default)
{
using var activity = EmailTelemetry.Source.StartActivity("Email Connect");
@@ -1,6 +1,6 @@
namespace LiteCharms.Features.Email.Models;
public class Attachment
public sealed class Attachment
{
public string? Name { get; set; }
+1 -1
View File
@@ -1,6 +1,6 @@
namespace LiteCharms.Features.Email.Models;
public class Body : IDisposable
public sealed class Body : IDisposable
{
public string? Message { get; set; }
@@ -1,6 +1,6 @@
namespace LiteCharms.Features.Email.Models;
public class BodyProperties
public sealed class BodyProperties
{
public bool IsHtml { get; set; }
+1 -1
View File
@@ -1,6 +1,6 @@
namespace LiteCharms.Features.Email.Models;
public class Message : IDisposable
public sealed class Message : IDisposable
{
public Party? Sender { get; set; }
+1 -1
View File
@@ -1,6 +1,6 @@
namespace LiteCharms.Features.Email.Models;
public class Party
public sealed class Party
{
public string? Name { get; set; }
+1 -1
View File
@@ -1,6 +1,6 @@
namespace LiteCharms.Features.Email.Models;
public class Response
public sealed class Response
{
public int Code { get; set; }
+3 -3
View File
@@ -2,12 +2,12 @@
public static class Hash
{
public static Func<string?, string?> StringToSha256Hash = (input) =>
public static readonly Func<string?, string?> StringToSha256Hash = (input) =>
Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(input!)));
public static Func<Stream, string?> StreamToSha256Hash = (stream) =>
public static readonly Func<Stream, string?> StreamToSha256Hash = (stream) =>
Convert.ToHexString(SHA256.HashData(stream));
public static Func<byte[], string?> BytesToSha256Hash = (bytes) =>
public static readonly Func<byte[], string?> BytesToSha256Hash = (bytes) =>
Convert.ToHexString(SHA256.HashData(bytes));
}
+1 -1
View File
@@ -64,7 +64,7 @@ public static class Quartz
config.UseDefaultThreadPool(options => options.MaxConcurrency = 1);
config.UseTimeZoneConverter();
config.SetProperty("quartz.jobStore.misfireThreshold", TimeSpan.FromMinutes(2).TotalMilliseconds.ToString());
config.SetProperty("quartz.jobStore.misfireThreshold", TimeSpan.FromMinutes(2).TotalMilliseconds.ToString(CultureInfo.InvariantCulture));
config.UsePersistentStore(storage =>
{
+1 -1
View File
@@ -20,7 +20,7 @@ public static class Timezones
? new DateTimeOffset(sourceDateAdjusted.Ticks, SouthAfricanTimeZone.BaseUtcOffset).LocaliseDateTimeOffset(SouthAfricanTimeZone.BaseUtcOffset)
: new DateTimeOffset(sourceDateAdjusted.Ticks, timezone!.BaseUtcOffset).LocaliseDateTimeOffset(timezone.BaseUtcOffset);
return DateTimeOffset.Parse(localised!);
return DateTimeOffset.Parse(localised!, CultureInfo.InvariantCulture);
}
public static DateTime UtcNow(this TimeZoneInfo timezone) => ToDateTimeWithTimeZone(DateTime.Now, timezone).UtcDateTime;
@@ -31,6 +31,10 @@
<!-- Quartz Scheduler-->
<ItemGroup>
<PackageReference Include="Meziantou.Analyzer" Version="3.0.96">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="OpenTelemetry" Version="1.15.3" />
<PackageReference Include="Quartz" Version="3.18.1" />
<PackageReference Include="Quartz.Plugins" Version="3.18.1" />
@@ -142,6 +146,7 @@
<!-- Shared Usings -->
<ItemGroup>
<Using Include="System.Globalization" />
<Using Include="Microsoft.AspNetCore.Builder" />
<Using Include="Microsoft.Extensions.Hosting" />
<Using Include="System.Text" />
+1 -1
View File
@@ -1,6 +1,6 @@
namespace LiteCharms.Features.Models;
public class DateRange
public sealed class DateRange
{
public DateOnly From { get; set; }
+1 -1
View File
@@ -1,6 +1,6 @@
namespace LiteCharms.Features.Models;
public class PageReference
public sealed class PageReference
{
public string? Tag { get; set; }
+1 -1
View File
@@ -1,6 +1,6 @@
namespace LiteCharms.Features.Models;
public class ProductFilter
public sealed class ProductFilter
{
public string? Name { get; set; }
@@ -1,6 +1,6 @@
namespace LiteCharms.Features.Models;
public class ProductMetadata
public sealed class ProductMetadata
{
public string? Manufacturer { get; set; }
+1 -1
View File
@@ -1,7 +1,7 @@
namespace LiteCharms.Features.Models;
public class SocialMedia
public sealed class SocialMedia
{
public SocialMediaTypes Type { get; set; }
@@ -3,7 +3,7 @@ using LiteCharms.Features.Quartz.Abstractions;
namespace LiteCharms.Features.Quartz;
public class JobOrchestrator(ISchedulerFactory schedulerFactory) : IJobOrchestrator
public sealed class JobOrchestrator(ISchedulerFactory schedulerFactory) : IJobOrchestrator
{
public async Task SendAsync<TNotification>(TNotification notification, CancellationToken cancellationToken = default)
where TNotification : IEvent
@@ -11,7 +11,7 @@ public class JobOrchestrator(ISchedulerFactory schedulerFactory) : IJobOrchestra
var chainedJobGroup = "onetime-jobs";
var scheduler = await schedulerFactory.GetScheduler(cancellationToken);
var jobKey = new JobKey($"{notification.Name.ToLower()}-{notification.CorrelationId.ToLower()}", chainedJobGroup);
var jobKey = new JobKey($"{notification.Name.ToLower(CultureInfo.InvariantCulture)}-{notification.CorrelationId.ToLower(CultureInfo.InvariantCulture)}", chainedJobGroup);
var triggerKey = new TriggerKey($"{jobKey.Name}-trigger", chainedJobGroup);
var job = JobBuilder.Create<MediatorJob<TNotification>>()
@@ -35,7 +35,7 @@ public class JobOrchestrator(ISchedulerFactory schedulerFactory) : IJobOrchestra
var chainedJobGroup = "scheduled-jobs";
var scheduler = await schedulerFactory.GetScheduler(cancellationToken);
var jobKey = new JobKey($"{notification.Name.ToLower()}", chainedJobGroup);
var jobKey = new JobKey($"{notification.Name.ToLower(CultureInfo.InvariantCulture)}", chainedJobGroup);
var triggerKey = new TriggerKey($"{jobKey.Name}-trigger", chainedJobGroup);
var job = JobBuilder.Create<MediatorJob<TNotification>>()
@@ -53,7 +53,7 @@ public class JobOrchestrator(ISchedulerFactory schedulerFactory) : IJobOrchestra
.WithDescription($"Scheduled via Main Job at {now:g}")
.WithCronSchedule(cronExpression, cron => cron
.WithMisfireHandlingInstructionIgnoreMisfires())
.StartAt(now)
.StartAt((DateTimeOffset)now)
.Build();
await scheduler.AddJob(job, replace: true, cancellationToken);
+1 -1
View File
@@ -4,7 +4,7 @@ using LiteCharms.Features.Mediator;
namespace LiteCharms.Features.Quartz;
[DisallowConcurrentExecution]
public class MediatorJob<TNotification>(IMediator mediator) : IJob where TNotification : IEvent
public sealed class MediatorJob<TNotification>(IMediator mediator) : IJob where TNotification : IEvent
{
public async Task Execute(IJobExecutionContext context)
{
@@ -1,6 +1,6 @@
namespace LiteCharms.Features.Quartz;
public class RetryJobListener : IJobListener
public sealed class RetryJobListener : IJobListener
{
public string Name => "RetryJobListener";
@@ -31,7 +31,7 @@ public abstract class S3ServiceBase(IAmazonS3 amazonS3)
if(string.IsNullOrWhiteSpace(fileHash))
return Result.Fail<string>("Failed to compute file hash.");
var fileKey = $"{fileHash.ToLower()}{Path.GetExtension(fileName)}";
var fileKey = $"{fileHash.ToLower(CultureInfo.InvariantCulture)}{Path.GetExtension(fileName)}";
var putRequest = new PutObjectRequest
{
@@ -3,7 +3,7 @@ using static LiteCharms.Features.S3.Constants;
namespace LiteCharms.Features.S3;
public class BookshopInvoicesS3Service(IConfiguration configuration, [FromKeyedServices(BookshopInvoicesBucketName)] IAmazonS3 amazonS3) :
public sealed class BookshopInvoicesS3Service(IConfiguration configuration, [FromKeyedServices(BookshopInvoicesBucketName)] IAmazonS3 amazonS3) :
S3ServiceBase(amazonS3), IS3Service
{
protected override string BucketName => configuration.GetSection($"{BookshopInvoicesS3SettingsSection}:BucketName").Value ?? "";
@@ -3,7 +3,7 @@ using static LiteCharms.Features.S3.Constants;
namespace LiteCharms.Features.S3;
public class BookshopQuotesS3Service(IConfiguration configuration, [FromKeyedServices(BookshopQuotesBucketName)] IAmazonS3 amazonS3) :
public sealed class BookshopQuotesS3Service(IConfiguration configuration, [FromKeyedServices(BookshopQuotesBucketName)] IAmazonS3 amazonS3) :
S3ServiceBase(amazonS3), IS3Service
{
protected override string BucketName => configuration.GetSection($"{BookshopQuotesS3SettingsSection}:BucketName").Value ?? "";
+1 -1
View File
@@ -3,7 +3,7 @@ using static LiteCharms.Features.S3.Constants;
namespace LiteCharms.Features.S3;
public class BookshopS3Service(IConfiguration configuration, [FromKeyedServices(BookshopBucketName)] IAmazonS3 amazonS3) :
public sealed class BookshopS3Service(IConfiguration configuration, [FromKeyedServices(BookshopBucketName)] IAmazonS3 amazonS3) :
S3ServiceBase(amazonS3), IS3Service
{
protected override string BucketName => configuration.GetSection($"{BookshopS3SettingsSection}:BucketName").Value ?? "";
@@ -1,6 +1,6 @@
namespace LiteCharms.Features.S3.Configuration;
public class S3Settings
public sealed class S3Settings
{
public string? ServiceUrl { get; set; }
@@ -4,7 +4,7 @@ using LiteCharms.Features.ServiceBus.Queues;
namespace LiteCharms.Features.ServiceBus;
public class EmailServiceBus(EmailQueue messages) : IEventBus
public sealed class EmailServiceBus(EmailQueue messages) : IEventBus
{
public async Task<Result> PublishAsync<TEvent>(TEvent notification, CancellationToken cancellationToken = default)
where TEvent : class, IEvent
@@ -3,7 +3,7 @@ using LiteCharms.Features.ServiceBus.Queues;
namespace LiteCharms.Features.ServiceBus.Exchanges;
public class EmailExchange(EmailQueue messages, ILogger<EmailExchange> logger, IPublisher mediator) : BackgroundService
public sealed class EmailExchange(EmailQueue messages, ILogger<EmailExchange> logger, IPublisher mediator) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
@@ -2,7 +2,7 @@
namespace LiteCharms.Features.ServiceBus.Exchanges;
public class GeneralExchange(GeneralQueue messages) : BackgroundService
public sealed class GeneralExchange(GeneralQueue messages) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
@@ -2,7 +2,7 @@
namespace LiteCharms.Features.ServiceBus.Exchanges;
public class SalesExchange(SalesQueue messages) : BackgroundService
public sealed class SalesExchange(SalesQueue messages) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
@@ -4,7 +4,7 @@ using LiteCharms.Features.ServiceBus.Queues;
namespace LiteCharms.Features.ServiceBus;
public class GeneralServiceBus(GeneralQueue messages) : IEventBus
public sealed class GeneralServiceBus(GeneralQueue messages) : IEventBus
{
public async Task<Result> PublishAsync<TEvent>(TEvent notification, CancellationToken cancellationToken = default)
where TEvent : class, IEvent
@@ -2,4 +2,4 @@
namespace LiteCharms.Features.ServiceBus.Queues;
public class EmailQueue : EventBusQueueBase, IEventBusQueue;
public sealed class EmailQueue : EventBusQueueBase, IEventBusQueue;
@@ -2,4 +2,4 @@
namespace LiteCharms.Features.ServiceBus.Queues;
public class GeneralQueue : EventBusQueueBase, IEventBusQueue;
public sealed class GeneralQueue : EventBusQueueBase, IEventBusQueue;
@@ -2,4 +2,4 @@
namespace LiteCharms.Features.ServiceBus.Queues;
public class SalesQueue : EventBusQueueBase, IEventBusQueue;
public sealed class SalesQueue : EventBusQueueBase, IEventBusQueue;
@@ -4,7 +4,7 @@ using LiteCharms.Features.ServiceBus.Queues;
namespace LiteCharms.Features.ServiceBus;
public class SalesServiceBus(SalesQueue messages) : IEventBus
public sealed class SalesServiceBus(SalesQueue messages) : IEventBus
{
public async Task<Result> PublishAsync<TEvent>(TEvent notification, CancellationToken cancellationToken = default)
where TEvent : class, IEvent