Skip to content

Commit c93374c

Browse files
authored
Merge pull request #28 from Azm-Tech/feat/add-system-notificaton-services
Feat/add system notificaton services
2 parents cb4f91f + 0c55dc6 commit c93374c

118 files changed

Lines changed: 6636 additions & 323 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

backend/Directory.Packages.props

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,14 @@
110110
<PackageVersion Include="WireMock.Net" Version="1.7.0" />
111111
</ItemGroup>
112112

113+
<ItemGroup Label="Messaging (MassTransit)">
114+
<!-- MassTransit core + RabbitMQ transport. All MIT-licensed, no commercial licence required. -->
115+
<PackageVersion Include="MassTransit" Version="8.3.7" />
116+
<PackageVersion Include="MassTransit.RabbitMQ" Version="8.3.7" />
117+
<!-- In-memory transport used in tests and when Messaging:Transport=InMemory -->
118+
<PackageVersion Include="MassTransit.Testing.Helpers" Version="8.3.7" />
119+
</ItemGroup>
120+
113121
<ItemGroup Label="Rate limiting (built-in 8+, no package) and problem-details helpers">
114122
<PackageVersion Include="Hellang.Middleware.ProblemDetails" Version="6.5.1" />
115123
</ItemGroup>
@@ -146,4 +154,13 @@
146154
<PackageVersion Include="YamlDotNet" Version="16.3.0" />
147155
</ItemGroup>
148156

157+
<ItemGroup Label="Seq + OpenTelemetry">
158+
<PackageVersion Include="Serilog.Sinks.Seq" Version="8.0.0" />
159+
<PackageVersion Include="OpenTelemetry" Version="1.11.2" />
160+
<PackageVersion Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.11.2" />
161+
<PackageVersion Include="OpenTelemetry.Extensions.Hosting" Version="1.11.2" />
162+
<PackageVersion Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.11.0" />
163+
<PackageVersion Include="OpenTelemetry.Instrumentation.Http" Version="1.11.0" />
164+
</ItemGroup>
165+
149166
</Project>

backend/permissions.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,12 @@ groups:
152152
TemplateManage:
153153
description: Manage notification templates
154154
roles: [cce-super-admin, cce-admin]
155+
LogView:
156+
description: View notification logs and retry failed deliveries
157+
roles: [cce-super-admin, cce-admin]
158+
Send:
159+
description: Send manual/admin notifications
160+
roles: [cce-super-admin, cce-admin]
155161
Audit:
156162
Read:
157163
description: Query the audit-event log

backend/src/CCE.Api.Common/Localization/Resources.yaml

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -411,3 +411,23 @@ SETTINGS_UPDATED:
411411
CONTENT_UPDATE_FAILED:
412412
ar: "عذراً، حدثت مشكلة أثناء تحديث المحتوى"
413413
en: "Sorry, a problem occurred while updating the content"
414+
415+
NOTIFICATION_SETTINGS_UPDATED:
416+
ar: "تم تحديث إعدادات الإشعارات بنجاح"
417+
en: "Notification settings updated successfully"
418+
419+
NOTIFICATION_RETRIED:
420+
ar: "تمت إعادة إرسال الإشعار بنجاح"
421+
en: "Notification retried successfully"
422+
423+
NOTIFICATIONS_MARKED_READ:
424+
ar: "تم تحديد الإشعارات كمقروءة"
425+
en: "Notifications marked as read"
426+
427+
NOTIFICATION_TEMPLATE_CREATED:
428+
ar: "تم إنشاء قالب الإشعار بنجاح"
429+
en: "Notification template created successfully"
430+
431+
NOTIFICATION_TEMPLATE_UPDATED:
432+
ar: "تم تحديث قالب الإشعار بنجاح"
433+
en: "Notification template updated successfully"

backend/src/CCE.Api.External/Endpoints/NotificationsEndpoints.cs

Lines changed: 36 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
1+
using CCE.Api.Common.Extensions;
12
using CCE.Application.Common.Interfaces;
23
using CCE.Application.Notifications.Public.Commands.MarkAllNotificationsRead;
34
using CCE.Application.Notifications.Public.Commands.MarkNotificationRead;
5+
using CCE.Application.Notifications.Public.Commands.UpdateMyNotificationSettings;
6+
using CCE.Application.Notifications.Public.Queries.GetMyNotificationSettings;
47
using CCE.Application.Notifications.Public.Queries.GetMyUnreadCount;
58
using CCE.Application.Notifications.Public.Queries.ListMyNotifications;
69
using CCE.Domain.Notifications;
@@ -28,7 +31,7 @@ public static IEndpointRouteBuilder MapNotificationsEndpoints(this IEndpointRout
2831
if (userId == System.Guid.Empty) return Results.Unauthorized();
2932
var query = new ListMyNotificationsQuery(userId, page ?? 1, pageSize ?? 20, status);
3033
var result = await mediator.Send(query, ct).ConfigureAwait(false);
31-
return Results.Ok(result);
34+
return result.ToHttpResult();
3235
}).WithName("ListMyNotifications");
3336

3437
notif.MapGet("/unread-count", async (
@@ -37,8 +40,8 @@ public static IEndpointRouteBuilder MapNotificationsEndpoints(this IEndpointRout
3740
{
3841
var userId = currentUser.GetUserId() ?? System.Guid.Empty;
3942
if (userId == System.Guid.Empty) return Results.Unauthorized();
40-
var count = await mediator.Send(new GetMyUnreadCountQuery(userId), ct).ConfigureAwait(false);
41-
return Results.Ok(new { count });
43+
var result = await mediator.Send(new GetMyUnreadCountQuery(userId), ct).ConfigureAwait(false);
44+
return result.ToHttpResult();
4245
}).WithName("GetMyUnreadNotificationCount");
4346

4447
notif.MapPost("/{id:guid}/mark-read", async (
@@ -48,8 +51,8 @@ public static IEndpointRouteBuilder MapNotificationsEndpoints(this IEndpointRout
4851
{
4952
var userId = currentUser.GetUserId() ?? System.Guid.Empty;
5053
if (userId == System.Guid.Empty) return Results.Unauthorized();
51-
await mediator.Send(new MarkNotificationReadCommand(id, userId), ct).ConfigureAwait(false);
52-
return Results.NoContent();
54+
var result = await mediator.Send(new MarkNotificationReadCommand(id, userId), ct).ConfigureAwait(false);
55+
return result.ToHttpResult();
5356
}).WithName("MarkNotificationRead");
5457

5558
notif.MapPost("/mark-all-read", async (
@@ -58,10 +61,36 @@ public static IEndpointRouteBuilder MapNotificationsEndpoints(this IEndpointRout
5861
{
5962
var userId = currentUser.GetUserId() ?? System.Guid.Empty;
6063
if (userId == System.Guid.Empty) return Results.Unauthorized();
61-
var marked = await mediator.Send(new MarkAllNotificationsReadCommand(userId), ct).ConfigureAwait(false);
62-
return Results.Ok(new { marked });
64+
var result = await mediator.Send(new MarkAllNotificationsReadCommand(userId), ct).ConfigureAwait(false);
65+
return result.ToHttpResult();
6366
}).WithName("MarkAllNotificationsRead");
6467

68+
notif.MapGet("/settings", async (
69+
ICurrentUserAccessor currentUser,
70+
IMediator mediator, CancellationToken ct) =>
71+
{
72+
var userId = currentUser.GetUserId() ?? System.Guid.Empty;
73+
if (userId == System.Guid.Empty) return Results.Unauthorized();
74+
var result = await mediator.Send(new GetMyNotificationSettingsQuery(userId), ct).ConfigureAwait(false);
75+
return result.ToHttpResult();
76+
}).WithName("GetMyNotificationSettings");
77+
78+
notif.MapPut("/settings", async (
79+
UpdateMyNotificationSettingsRequest body,
80+
ICurrentUserAccessor currentUser,
81+
IMediator mediator, CancellationToken ct) =>
82+
{
83+
var userId = currentUser.GetUserId() ?? System.Guid.Empty;
84+
if (userId == System.Guid.Empty) return Results.Unauthorized();
85+
var command = new UpdateMyNotificationSettingsCommand(
86+
userId,
87+
body.Channel,
88+
body.IsEnabled,
89+
body.EventCode);
90+
var result = await mediator.Send(command, ct).ConfigureAwait(false);
91+
return result.ToHttpResult();
92+
}).WithName("UpdateMyNotificationSettings");
93+
6594
return app;
6695
}
6796
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
using CCE.Domain.Notifications;
2+
3+
namespace CCE.Api.External.Endpoints;
4+
5+
public sealed record UpdateMyNotificationSettingsRequest(
6+
NotificationChannel Channel,
7+
bool IsEnabled,
8+
string? EventCode = null);
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
using System.Security.Claims;
2+
using Microsoft.AspNetCore.SignalR;
3+
4+
namespace CCE.Api.External.Hubs;
5+
6+
/// <summary>
7+
/// Routes SignalR messages by the JWT <c>sub</c> claim so that
8+
/// <c>Clients.User(userId)</c> matches the CCE user identifier.
9+
/// </summary>
10+
public sealed class SubClaimUserIdProvider : IUserIdProvider
11+
{
12+
public string? GetUserId(HubConnectionContext connection)
13+
{
14+
return connection.User?.FindFirstValue("sub")
15+
?? connection.User?.FindFirstValue(ClaimTypes.NameIdentifier);
16+
}
17+
}

backend/src/CCE.Api.External/Program.cs

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,15 @@
88
using CCE.Api.Common.OpenApi;
99
using CCE.Api.Common.RateLimiting;
1010
using CCE.Api.External.Endpoints;
11+
using CCE.Api.External.Hubs;
1112
using CCE.Application;
13+
using CCE.Infrastructure.Notifications;
1214
using CCE.Application.Common.CountryScope;
1315
using CCE.Application.Common.Interfaces;
1416
using CCE.Application.Health;
1517
using CCE.Infrastructure;
1618
using MediatR;
19+
using Microsoft.AspNetCore.SignalR;
1720
using Microsoft.Extensions.DependencyInjection.Extensions;
1821
using Serilog;
1922
using System.Globalization;
@@ -50,6 +53,8 @@
5053
builder.Services.AddHttpContextAccessor();
5154
builder.Services.Replace(ServiceDescriptor.Scoped<ICurrentUserAccessor, HttpContextCurrentUserAccessor>());
5255
builder.Services.Replace(ServiceDescriptor.Scoped<ICountryScopeAccessor, HttpContextCountryScopeAccessor>());
56+
builder.Services.Replace(ServiceDescriptor.Singleton<IUserIdProvider, SubClaimUserIdProvider>());
57+
builder.Services.AddSignalR().AddJsonProtocol();
5358

5459
var app = builder.Build();
5560

@@ -83,9 +88,11 @@
8388
// deployments leave the flag false → endpoints are never mounted.
8489
if (builder.Configuration.GetValue<bool>("Auth:DevMode"))
8590
{
86-
app.MapDevAuthEndpoints();
91+
app.MapDevAuthEndpoints();
8792
}
8893

94+
app.MapHub<NotificationsHub>("/hubs/notifications");
95+
8996
app.MapProfileEndpoints();
9097
app.MapAuthEndpoints(CCE.Application.Identity.Auth.Common.LocalAuthApi.External);
9198
app.MapNotificationsEndpoints();

backend/src/CCE.Api.External/appsettings.Development.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,5 +88,13 @@
8888
},
8989
"Seq": {
9090
"ServerUrl": "http://localhost:5341"
91+
},
92+
"Messaging": {
93+
"Transport": "InMemory",
94+
"UseAsyncDispatcher": true
95+
// For RabbitMQ production:
96+
// "Transport": "RabbitMQ",
97+
// "RabbitMqHost": "amqp://guest:guest@localhost",
98+
// "RabbitMqVirtualHost": "/cce-dev"
9199
}
92100
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
using CCE.Domain.Notifications;
2+
3+
namespace CCE.Api.Internal.Endpoints;
4+
5+
public sealed record CreateNotificationTemplateRequest(
6+
string Code,
7+
string SubjectAr,
8+
string SubjectEn,
9+
string BodyAr,
10+
string BodyEn,
11+
NotificationChannel Channel,
12+
string VariableSchemaJson);
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
using CCE.Api.Common.Extensions;
2+
using CCE.Application.Notifications.Admin.Commands.RetryNotificationLog;
3+
using CCE.Application.Notifications.Admin.Queries.GetNotificationLogById;
4+
using CCE.Application.Notifications.Admin.Queries.ListNotificationLogs;
5+
using CCE.Domain;
6+
using MediatR;
7+
using Microsoft.AspNetCore.Builder;
8+
using Microsoft.AspNetCore.Http;
9+
using Microsoft.AspNetCore.Routing;
10+
11+
namespace CCE.Api.Internal.Endpoints;
12+
13+
public static class NotificationLogEndpoints
14+
{
15+
public static IEndpointRouteBuilder MapNotificationLogEndpoints(this IEndpointRouteBuilder app)
16+
{
17+
var group = app.MapGroup("/api/admin/notification-logs")
18+
.WithTags("Notification Logs")
19+
.RequireAuthorization(Permissions.Notification_LogView);
20+
21+
group.MapGet("", async (
22+
int? page, int? pageSize,
23+
Guid? recipientUserId, string? templateCode, int? channel, int? status,
24+
IMediator mediator, CancellationToken ct) =>
25+
{
26+
var query = new ListNotificationLogsQuery(
27+
page ?? 1,
28+
pageSize ?? 20,
29+
recipientUserId,
30+
templateCode,
31+
channel is { } c ? (CCE.Domain.Notifications.NotificationChannel)c : null,
32+
status is { } s ? (CCE.Domain.Notifications.NotificationDeliveryStatus)s : null);
33+
var result = await mediator.Send(query, ct).ConfigureAwait(false);
34+
return result.ToHttpResult();
35+
}).WithName("ListNotificationLogs");
36+
37+
group.MapGet("/{id:guid}", async (
38+
Guid id,
39+
IMediator mediator, CancellationToken ct) =>
40+
{
41+
var result = await mediator.Send(new GetNotificationLogByIdQuery(id), ct).ConfigureAwait(false);
42+
return result.ToHttpResult();
43+
}).WithName("GetNotificationLogById");
44+
45+
group.MapPost("/{id:guid}/retry", async (
46+
Guid id,
47+
IMediator mediator, CancellationToken ct) =>
48+
{
49+
var result = await mediator.Send(new RetryNotificationLogCommand(id), ct).ConfigureAwait(false);
50+
return result.ToHttpResult();
51+
}).WithName("RetryNotificationLog");
52+
53+
return app;
54+
}
55+
}

0 commit comments

Comments
 (0)