Compare commits

...

16 Commits

Author SHA1 Message Date
khwezi 41f7c05be3 Merge pull request 'Refactored service to internalise the CDN' (#32) from s3service into master
Reviewed-on: #32
2026-05-19 11:34:51 +02:00
Khwezi Mngoma 52d204e286 Refactored service to internalise the CDN
continuous-integration/drone/pr Build is passing
2026-05-19 11:34:00 +02:00
khwezi 1a03355e84 Merge pull request 'Added S3 support' (#31) from s3service into master
Reviewed-on: #31
2026-05-19 10:24:05 +02:00
Khwezi Mngoma f245bc94e1 Added S3 support
continuous-integration/drone/pr Build is passing
2026-05-19 10:23:36 +02:00
khwezi 7743c3178e Merge pull request 'Simplified notification updating' (#30) from emailjobs into master
Reviewed-on: #30
2026-05-17 16:01:24 +02:00
Khwezi Mngoma da141311ff Simplified notification updating
continuous-integration/drone/pr Build is passing
2026-05-17 16:00:35 +02:00
khwezi ab3d8e6e9a Merge pull request 'Refactored GetNotificationsAsync() date handling' (#29) from emailjobs into master
Reviewed-on: #29
2026-05-17 13:14:01 +02:00
Khwezi Mngoma 97bde73777 Refactored GetNotificationsAsync() date handling
continuous-integration/drone/pr Build is passing
2026-05-17 13:12:58 +02:00
khwezi db4c348288 Merge pull request 'Fixed email sending logic' (#28) from emailjobs into master
Reviewed-on: #28
2026-05-16 00:29:01 +02:00
Khwezi Mngoma a65e926a53 Fixed email sending logic
continuous-integration/drone/pr Build is passing
2026-05-16 00:28:31 +02:00
khwezi 6683234642 Merge pull request 'Refactored batch drop logic' (#27) from emailjobs into master
Reviewed-on: #27
2026-05-16 00:05:51 +02:00
Khwezi Mngoma 1471d9e597 Refactored batch drop logic
continuous-integration/drone/pr Build is passing
2026-05-16 00:04:58 +02:00
khwezi 6ddbb9479a Merge pull request 'Added an empty constructor to ProcessEmailNotificationEvent' (#26) from emailjobs into master
Reviewed-on: #26
2026-05-15 23:53:09 +02:00
Khwezi Mngoma e978aa17f8 Added an empty constructor to ProcessEmailNotificationEvent
continuous-integration/drone/pr Build is passing
2026-05-15 23:52:38 +02:00
khwezi 6c7349a0f8 Merge pull request 'Added additional logging and traces' (#25) from emailjobs into master
Reviewed-on: #25
2026-05-15 23:21:52 +02:00
Khwezi Mngoma a31f75c5ef Added additional logging and traces
continuous-integration/drone/pr Build is passing
2026-05-15 23:21:31 +02:00
16 changed files with 290 additions and 22 deletions
@@ -1,4 +1,6 @@
using LiteCharms.Features.Shop.Notifications; using LiteCharms.Features.Models;
using LiteCharms.Features.Shop.Notifications;
using LiteCharms.Features.Shop.Notifications.Events;
namespace LiteCharms.Features.Tests; namespace LiteCharms.Features.Tests;
@@ -32,4 +34,31 @@ public class NotificationsFeatureTests(CommonFixture fixture, ITestOutputHelper
foreach (var error in createResult.Errors) output.WriteLine(error.Message); foreach (var error in createResult.Errors) output.WriteLine(error.Message);
} }
[Fact]
public async Task GetNotifications_ShouldReturn_AllNotifications()
{
DateRange range = new()
{
From = DateOnly.FromDateTime(new DateTime(2026, 04, 01, 0, 0, 0, DateTimeKind.Utc)),
To = DateOnly.FromDateTime(DateTime.UtcNow),
MaxRecords = 10
};
var getResult = await notificationService.GetNotificationsAsync(range);
Assert.True(getResult.IsSuccess);
foreach (var error in getResult.Errors) output.WriteLine(error.Message);
}
[Fact]
public async Task ProcessEmailNotificationsEvent_ShouldSucceed()
{
var notification = ProcessEmailNotificationsEvent.Create();
await fixture.Mediator.Publish(notification);
Assert.True(true);
}
} }
+3 -2
View File
@@ -35,8 +35,9 @@ public class EmailService(IOptions<SmtpSettings> options) : IDisposable
var bodyBuilder = new BodyBuilder(); var bodyBuilder = new BodyBuilder();
foreach (var attachment in message.Body?.Attachments!) if (message.Body!.Properties.HasAttachments)
bodyBuilder.Attachments.Add(attachment.Name!, attachment.FileStream!, cancellationToken); 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.TextBody = message.Body.Message;
if (message.Body.Properties.IsHtml) bodyBuilder.HtmlBody = message.Body.Message; if (message.Body.Properties.IsHtml) bodyBuilder.HtmlBody = message.Body.Message;
+58
View File
@@ -0,0 +1,58 @@
using LiteCharms.Features.S3;
using LiteCharms.Features.S3.Abstractions;
using static LiteCharms.Features.S3.Constants;
namespace LiteCharms.Features.Extensions;
public static class S3
{
public static IServiceCollection AddGarageS3(this IServiceCollection services, IConfiguration configuration)
{
if (configuration.GetSection(BookshopBucketName) is not null)
{
services.AddKeyedSingleton<IAmazonS3, AmazonS3Client>(BookshopBucketName, (provider, client) =>
new AmazonS3Client(new BasicAWSCredentials(configuration.GetSection($"{BookshopS3SettingsSection}:AccessKey").Value,
configuration.GetSection($"{BookshopS3SettingsSection}:SecretKey").Value),
new AmazonS3Config
{
ServiceURL = configuration.GetSection($"{BookshopS3SettingsSection}:ServiceUrl").Value,
AuthenticationRegion = configuration.GetSection($"{BookshopS3SettingsSection}:Region").Value,
ForcePathStyle = true,
}));
services.AddKeyedScoped<IS3Service, BookstoreS3Service>(BookshopBucketName);
}
if (configuration.GetSection(BookshopInvoicesBucketName) is not null)
{
services.AddKeyedSingleton<IAmazonS3, AmazonS3Client>(BookshopInvoicesBucketName, (provider, client) =>
new AmazonS3Client(new BasicAWSCredentials(configuration.GetSection($"{BookshopInvoicesBucketName}:AccessKey").Value,
configuration.GetSection($"{BookshopInvoicesBucketName}:SecretKey").Value),
new AmazonS3Config
{
ServiceURL = configuration.GetSection($"{BookshopInvoicesBucketName}:ServiceUrl").Value,
AuthenticationRegion = configuration.GetSection($"{BookshopInvoicesBucketName}:Region").Value,
ForcePathStyle = true,
}));
services.AddKeyedScoped<IS3Service, BookstoreInvoicesS3Service>(BookshopInvoicesBucketName);
}
if (configuration.GetSection(BookshopQuotesBucketName) is not null)
{
services.AddKeyedSingleton<IAmazonS3, AmazonS3Client>(BookshopQuotesBucketName, (provider, client) =>
new AmazonS3Client(new BasicAWSCredentials(configuration.GetSection($"{BookshopQuotesBucketName}:AccessKey").Value,
configuration.GetSection($"{BookshopQuotesBucketName}:SecretKey").Value),
new AmazonS3Config
{
ServiceURL = configuration.GetSection($"{BookshopQuotesBucketName}:ServiceUrl").Value,
AuthenticationRegion = configuration.GetSection($"{BookshopQuotesBucketName}:Region").Value,
ForcePathStyle = true,
}));
services.AddKeyedScoped<IS3Service, BookstoreQuotesS3Service>(BookshopQuotesBucketName);
}
return services;
}
}
@@ -128,6 +128,17 @@
<Using Include="FluentResults" /> <Using Include="FluentResults" />
<Using Include="Mediator" /> <Using Include="Mediator" />
</ItemGroup> </ItemGroup>
<!-- Amazon S3 SDK -->
<ItemGroup>
<PackageReference Include="AWSSDK.Extensions.NetCore.Setup" Version="4.0.3.40" />
<PackageReference Include="AWSSDK.S3" Version="4.0.23.3" />
<!-- global Usings -->
<Using Include="Amazon.S3" />
<Using Include="Amazon.S3.Model" />
<Using Include="Amazon.Runtime" />
</ItemGroup>
<!-- Shared Usings --> <!-- Shared Usings -->
<ItemGroup> <ItemGroup>
@@ -50,7 +50,7 @@ public class JobOrchestrator(ISchedulerFactory schedulerFactory) : IJobOrchestra
var trigger = global::Quartz.TriggerBuilder.Create() var trigger = global::Quartz.TriggerBuilder.Create()
.WithIdentity(triggerKey) .WithIdentity(triggerKey)
.WithDescription($"Scheduled via Main Job at {now:g} UTC") .WithDescription($"Scheduled via Main Job at {now:g}")
.WithCronSchedule(cronExpression, cron => cron .WithCronSchedule(cronExpression, cron => cron
.WithMisfireHandlingInstructionIgnoreMisfires()) .WithMisfireHandlingInstructionIgnoreMisfires())
.StartAt(now) .StartAt(now)
+14 -2
View File
@@ -10,16 +10,28 @@ public class MediatorJob<TNotification>(IMediator mediator) : IJob where TNotifi
{ {
var data = context.MergedJobDataMap["Payload"] as string; var data = context.MergedJobDataMap["Payload"] as string;
if (string.IsNullOrWhiteSpace(data)) return; if (string.IsNullOrWhiteSpace(data))
{
Trace.WriteLine("Job Payload missing, job ended");
return;
}
var notification = JsonSerializer.Deserialize<TNotification>(data); var notification = JsonSerializer.Deserialize<TNotification>(data);
if (notification is null) return; if (notification is null)
{
Trace.WriteLine("Notification could not be JSon converted from data string, job ended");
return;
}
using var activity = MediatorTelemetry.Source.StartActivity($"Quartz: {typeof(TNotification).Name}"); using var activity = MediatorTelemetry.Source.StartActivity($"Quartz: {typeof(TNotification).Name}");
activity?.SetTag("event.correlation_id", notification.CorrelationId); activity?.SetTag("event.correlation_id", notification.CorrelationId);
await mediator.Publish(notification, context.CancellationToken); await mediator.Publish(notification, context.CancellationToken);
Trace.WriteLine("Job published");
} }
} }
@@ -0,0 +1,6 @@
namespace LiteCharms.Features.S3.Abstractions;
public interface IS3Service
{
Task<Result<string>> UploadFileAsync(string fileName, Stream fileStream, string contentType, CancellationToken cancellationToken = default);
}
@@ -0,0 +1,8 @@
namespace LiteCharms.Features.S3.Abstractions;
public abstract class S3ServiceBase(IAmazonS3 amazonS3)
{
protected readonly IAmazonS3 client = amazonS3;
public abstract Task<Result<string>> UploadFileAsync(string fileName, Stream fileStream, string contentType, CancellationToken cancellationToken = default);
}
@@ -0,0 +1,38 @@
using LiteCharms.Features.S3.Abstractions;
namespace LiteCharms.Features.S3;
public class BookstoreInvoicesS3Service(IConfiguration configuration, [FromKeyedServices(Constants.BookshopInvoicesBucketName)] IAmazonS3 amazonS3) :
S3ServiceBase(amazonS3), IS3Service
{
public override async Task<Result<string>> UploadFileAsync(string fileName, Stream fileStream, string contentType, CancellationToken cancellationToken = default)
{
try
{
var bucketName = configuration.GetSection($"{Constants.BookshopInvoicesS3SettingsSection}:BucketName").Value!;
var cdnBaseUrl = configuration.GetSection($"{Constants.BookshopInvoicesS3SettingsSection}:CdnBaseUrl").Value!;
if(string.IsNullOrWhiteSpace(bucketName))
return Result.Fail<string>("Bucket name is not configured.");
if (string.IsNullOrWhiteSpace(cdnBaseUrl))
return Result.Fail<string>("CDN base URL is not configured.");
var response = await client.PutObjectAsync(new PutObjectRequest
{
BucketName = bucketName,
Key = fileName,
InputStream = fileStream,
ContentType = contentType
}, cancellationToken);
return response.HttpStatusCode != System.Net.HttpStatusCode.OK
? Result.Fail<string>($"Failed to upload {fileName} to S3.")
: Result.Ok(string.Format(cdnBaseUrl, bucketName, fileName));
}
catch (Exception ex)
{
return Result.Fail<string>(new Error($"Error uploading {fileName} to S3: {ex.Message}").CausedBy(ex));
}
}
}
@@ -0,0 +1,38 @@
using LiteCharms.Features.S3.Abstractions;
namespace LiteCharms.Features.S3;
public class BookstoreQuotesS3Service(IConfiguration configuration, [FromKeyedServices(Constants.BookshopQuotesBucketName)] IAmazonS3 amazonS3) :
S3ServiceBase(amazonS3), IS3Service
{
public override async Task<Result<string>> UploadFileAsync(string fileName, Stream fileStream, string contentType, CancellationToken cancellationToken = default)
{
try
{
var bucketName = configuration.GetSection($"{Constants.BookshopQuotesS3SettingsSection}:BucketName").Value!;
var cdnBaseUrl = configuration.GetSection($"{Constants.BookshopQuotesS3SettingsSection}:CdnBaseUrl").Value!;
if(string.IsNullOrWhiteSpace(bucketName))
return Result.Fail<string>("Bucket name is not configured.");
if (string.IsNullOrWhiteSpace(cdnBaseUrl))
return Result.Fail<string>("CDN base URL is not configured.");
var response = await client.PutObjectAsync(new PutObjectRequest
{
BucketName = bucketName,
Key = fileName,
InputStream = fileStream,
ContentType = contentType
}, cancellationToken);
return response.HttpStatusCode != System.Net.HttpStatusCode.OK
? Result.Fail<string>($"Failed to upload {fileName} to S3.")
: Result.Ok(string.Format(cdnBaseUrl, bucketName, fileName));
}
catch (Exception ex)
{
return Result.Fail<string>(new Error($"Error uploading {fileName} to S3: {ex.Message}").CausedBy(ex));
}
}
}
@@ -0,0 +1,38 @@
using LiteCharms.Features.S3.Abstractions;
namespace LiteCharms.Features.S3;
public class BookstoreS3Service(IConfiguration configuration, [FromKeyedServices(Constants.BookshopBucketName)] IAmazonS3 amazonS3) :
S3ServiceBase(amazonS3), IS3Service
{
public override async Task<Result<string>> UploadFileAsync(string fileName, Stream fileStream, string contentType, CancellationToken cancellationToken = default)
{
try
{
var bucketName = configuration.GetSection($"{Constants.BookshopS3SettingsSection}:BucketName").Value!;
var cdnBaseUrl = configuration.GetSection($"{Constants.BookshopS3SettingsSection}:CdnBaseUrl").Value!;
if(string.IsNullOrWhiteSpace(bucketName))
return Result.Fail<string>("Bucket name is not configured.");
if (string.IsNullOrWhiteSpace(cdnBaseUrl))
return Result.Fail<string>("CDN base URL is not configured.");
var response = await client.PutObjectAsync(new PutObjectRequest
{
BucketName = bucketName,
Key = fileName,
InputStream = fileStream,
ContentType = contentType
}, cancellationToken);
return response.HttpStatusCode != System.Net.HttpStatusCode.OK
? Result.Fail<string>($"Failed to upload {fileName} to S3.")
: Result.Ok(string.Format(cdnBaseUrl, bucketName, fileName));
}
catch (Exception ex)
{
return Result.Fail<string>(new Error($"Error uploading {fileName} to S3: {ex.Message}").CausedBy(ex));
}
}
}
@@ -0,0 +1,16 @@
namespace LiteCharms.Features.S3.Configuration;
public class S3Settings
{
public string? ServiceUrl { get; set; }
public string? AccessKey { get; set; }
public string? SecretKey { get; set; }
public string? BucketName { get; set; }
public string? Region { get; set; }
public string? CdnBaseUrl { get; set; }
}
+12
View File
@@ -0,0 +1,12 @@
namespace LiteCharms.Features.S3;
public static class Constants
{
public const string BookshopS3SettingsSection = "BookshopS3Settings";
public const string BookshopInvoicesS3SettingsSection = "BookshopInvoicesS3Settings";
public const string BookshopQuotesS3SettingsSection = "BookshopQuotesS3Settings";
public const string BookshopBucketName = "bookshop";
public const string BookshopInvoicesBucketName = "bookshop.invoices";
public const string BookshopQuotesBucketName = "bookshop.quotes";
}
@@ -1,4 +1,5 @@
using LiteCharms.Features.Email; using k8s.KubeConfigModels;
using LiteCharms.Features.Email;
using LiteCharms.Features.Shop.Notifications.Models; using LiteCharms.Features.Shop.Notifications.Models;
using LiteCharms.Features.Shop.Postgres; using LiteCharms.Features.Shop.Postgres;
@@ -13,21 +14,22 @@ public class ProcessEmailNotificationsEventHandler(IDbContextFactory<ShopDbConte
{ {
try try
{ {
logger.LogInformation("Started");
using var context = await contextFactory.CreateDbContextAsync(cancellationToken); using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
if (emailService.Status != EmailStatuses.Connected)
await emailService.ConnectAsync(cancellationToken);
var notifications = await context.Notifications var notifications = await context.Notifications
.OrderByDescending(o => o.CreatedAt) .OrderByDescending(o => o.CreatedAt)
.ThenBy(o => o.Priority) .ThenBy(o => o.Priority)
.Where(n => n.CorrelationIdType == CorrelationIdTypes.Email) .Where(n => n.Platform == NotificationPlatforms.Email &&
.Where(n => n.Direction == NotificationDirection.Outgoing) n.Direction == NotificationDirection.Outgoing && n.Processed == false)
.Take(message.MaxRecords) .Take(message.MaxRecords)
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
foreach (var notification in notifications) foreach (var notification in notifications)
{ {
if (dropBatch || cancellationToken.IsCancellationRequested) break; if (dropBatch) break;
var sendResult = await SendEmailAsync(notification,emailService, cancellationToken); var sendResult = await SendEmailAsync(notification,emailService, cancellationToken);
@@ -48,7 +50,7 @@ public class ProcessEmailNotificationsEventHandler(IDbContextFactory<ShopDbConte
notification.UpdatedAt = DateTime.UtcNow; notification.UpdatedAt = DateTime.UtcNow;
} }
await context.SaveChangesAsync(cancellationToken); await context.SaveChangesAsync(cancellationToken);
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -56,7 +58,7 @@ public class ProcessEmailNotificationsEventHandler(IDbContextFactory<ShopDbConte
} }
finally finally
{ {
logger.LogInformation("Finished"); await emailService.DisconnectAsync(cancellationToken);
} }
} }
@@ -8,6 +8,8 @@ public class ProcessEmailNotificationsEvent : EventBase, IEvent
public int MaxRecords { get; set; } public int MaxRecords { get; set; }
public ProcessEmailNotificationsEvent() { MaxRecords = 1000; }
private ProcessEmailNotificationsEvent(int maxRecords = 1000) => MaxRecords = maxRecords; private ProcessEmailNotificationsEvent(int maxRecords = 1000) => MaxRecords = maxRecords;
public static ProcessEmailNotificationsEvent Create(int maxRecords = 1000) => new(maxRecords); public static ProcessEmailNotificationsEvent Create(int maxRecords = 1000) => new(maxRecords);
@@ -63,8 +63,8 @@ public class NotificationService(IDbContextFactory<ShopDbContext> contextFactory
{ {
try try
{ {
var fromDate = range.From.ToDateTime(TimeOnly.MinValue); var fromDate = range.From.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc);
var toDate = range.To.ToDateTime(TimeOnly.MaxValue); var toDate = range.To.ToDateTime(TimeOnly.MaxValue, DateTimeKind.Utc);
using var context = await contextFactory.CreateDbContextAsync(cancellationToken); using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
@@ -96,12 +96,9 @@ public class NotificationService(IDbContextFactory<ShopDbContext> contextFactory
return Result.Fail(new Error($"Notification with id {request.NotificationId} not found.")); return Result.Fail(new Error($"Notification with id {request.NotificationId} not found."));
notification.Processed = request.Processed; notification.Processed = request.Processed;
notification.UpdatedAt = DateTime.UtcNow;
if (request.HasError) notification.HasError = request.HasError;
{ notification.Errors = request.Errors;
notification.HasError = request.HasError;
notification.Errors = request.Errors;
}
return await context.SaveChangesAsync(cancellationToken) > 0 return await context.SaveChangesAsync(cancellationToken) > 0
? Result.Ok() ? Result.Ok()