Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions backend/Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,14 @@
<PackageVersion Include="WireMock.Net" Version="1.7.0" />
</ItemGroup>

<ItemGroup Label="Messaging (MassTransit)">
<!-- MassTransit core + RabbitMQ transport. All MIT-licensed, no commercial licence required. -->
<PackageVersion Include="MassTransit" Version="8.3.7" />
<PackageVersion Include="MassTransit.RabbitMQ" Version="8.3.7" />
<!-- In-memory transport used in tests and when Messaging:Transport=InMemory -->
<PackageVersion Include="MassTransit.Testing.Helpers" Version="8.3.7" />
</ItemGroup>

<ItemGroup Label="Rate limiting (built-in 8+, no package) and problem-details helpers">
<PackageVersion Include="Hellang.Middleware.ProblemDetails" Version="6.5.1" />
</ItemGroup>
Expand Down Expand Up @@ -146,4 +154,13 @@
<PackageVersion Include="YamlDotNet" Version="16.3.0" />
</ItemGroup>

<ItemGroup Label="Seq + OpenTelemetry">
<PackageVersion Include="Serilog.Sinks.Seq" Version="8.0.0" />
<PackageVersion Include="OpenTelemetry" Version="1.11.2" />
<PackageVersion Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.11.2" />
<PackageVersion Include="OpenTelemetry.Extensions.Hosting" Version="1.11.2" />
<PackageVersion Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.11.0" />
<PackageVersion Include="OpenTelemetry.Instrumentation.Http" Version="1.11.0" />
</ItemGroup>

</Project>
6 changes: 6 additions & 0 deletions backend/permissions.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,12 @@ groups:
TemplateManage:
description: Manage notification templates
roles: [cce-super-admin, cce-admin]
LogView:
description: View notification logs and retry failed deliveries
roles: [cce-super-admin, cce-admin]
Send:
description: Send manual/admin notifications
roles: [cce-super-admin, cce-admin]
Audit:
Read:
description: Query the audit-event log
Expand Down
20 changes: 20 additions & 0 deletions backend/src/CCE.Api.Common/Localization/Resources.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -411,3 +411,23 @@ SETTINGS_UPDATED:
CONTENT_UPDATE_FAILED:
ar: "عذراً، حدثت مشكلة أثناء تحديث المحتوى"
en: "Sorry, a problem occurred while updating the content"

NOTIFICATION_SETTINGS_UPDATED:
ar: "تم تحديث إعدادات الإشعارات بنجاح"
en: "Notification settings updated successfully"

NOTIFICATION_RETRIED:
ar: "تمت إعادة إرسال الإشعار بنجاح"
en: "Notification retried successfully"

NOTIFICATIONS_MARKED_READ:
ar: "تم تحديد الإشعارات كمقروءة"
en: "Notifications marked as read"

NOTIFICATION_TEMPLATE_CREATED:
ar: "تم إنشاء قالب الإشعار بنجاح"
en: "Notification template created successfully"

NOTIFICATION_TEMPLATE_UPDATED:
ar: "تم تحديث قالب الإشعار بنجاح"
en: "Notification template updated successfully"
43 changes: 36 additions & 7 deletions backend/src/CCE.Api.External/Endpoints/NotificationsEndpoints.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
using CCE.Api.Common.Extensions;
using CCE.Application.Common.Interfaces;
using CCE.Application.Notifications.Public.Commands.MarkAllNotificationsRead;
using CCE.Application.Notifications.Public.Commands.MarkNotificationRead;
using CCE.Application.Notifications.Public.Commands.UpdateMyNotificationSettings;
using CCE.Application.Notifications.Public.Queries.GetMyNotificationSettings;
using CCE.Application.Notifications.Public.Queries.GetMyUnreadCount;
using CCE.Application.Notifications.Public.Queries.ListMyNotifications;
using CCE.Domain.Notifications;
Expand Down Expand Up @@ -28,7 +31,7 @@ public static IEndpointRouteBuilder MapNotificationsEndpoints(this IEndpointRout
if (userId == System.Guid.Empty) return Results.Unauthorized();
var query = new ListMyNotificationsQuery(userId, page ?? 1, pageSize ?? 20, status);
var result = await mediator.Send(query, ct).ConfigureAwait(false);
return Results.Ok(result);
return result.ToHttpResult();
}).WithName("ListMyNotifications");

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

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

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

notif.MapGet("/settings", async (
ICurrentUserAccessor currentUser,
IMediator mediator, CancellationToken ct) =>
{
var userId = currentUser.GetUserId() ?? System.Guid.Empty;
if (userId == System.Guid.Empty) return Results.Unauthorized();
var result = await mediator.Send(new GetMyNotificationSettingsQuery(userId), ct).ConfigureAwait(false);
return result.ToHttpResult();
}).WithName("GetMyNotificationSettings");

notif.MapPut("/settings", async (
UpdateMyNotificationSettingsRequest body,
ICurrentUserAccessor currentUser,
IMediator mediator, CancellationToken ct) =>
{
var userId = currentUser.GetUserId() ?? System.Guid.Empty;
if (userId == System.Guid.Empty) return Results.Unauthorized();
var command = new UpdateMyNotificationSettingsCommand(
userId,
body.Channel,
body.IsEnabled,
body.EventCode);
var result = await mediator.Send(command, ct).ConfigureAwait(false);
return result.ToHttpResult();
}).WithName("UpdateMyNotificationSettings");

return app;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
using CCE.Domain.Notifications;

namespace CCE.Api.External.Endpoints;

public sealed record UpdateMyNotificationSettingsRequest(
NotificationChannel Channel,
bool IsEnabled,
string? EventCode = null);
17 changes: 17 additions & 0 deletions backend/src/CCE.Api.External/Hubs/SubClaimUserIdProvider.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
using System.Security.Claims;
using Microsoft.AspNetCore.SignalR;

namespace CCE.Api.External.Hubs;

/// <summary>
/// Routes SignalR messages by the JWT <c>sub</c> claim so that
/// <c>Clients.User(userId)</c> matches the CCE user identifier.
/// </summary>
public sealed class SubClaimUserIdProvider : IUserIdProvider
{
public string? GetUserId(HubConnectionContext connection)
{
return connection.User?.FindFirstValue("sub")
?? connection.User?.FindFirstValue(ClaimTypes.NameIdentifier);
}
}
9 changes: 8 additions & 1 deletion backend/src/CCE.Api.External/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,15 @@
using CCE.Api.Common.OpenApi;
using CCE.Api.Common.RateLimiting;
using CCE.Api.External.Endpoints;
using CCE.Api.External.Hubs;
using CCE.Application;
using CCE.Infrastructure.Notifications;
using CCE.Application.Common.CountryScope;
using CCE.Application.Common.Interfaces;
using CCE.Application.Health;
using CCE.Infrastructure;
using MediatR;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Serilog;
using System.Globalization;
Expand Down Expand Up @@ -50,6 +53,8 @@
builder.Services.AddHttpContextAccessor();
builder.Services.Replace(ServiceDescriptor.Scoped<ICurrentUserAccessor, HttpContextCurrentUserAccessor>());
builder.Services.Replace(ServiceDescriptor.Scoped<ICountryScopeAccessor, HttpContextCountryScopeAccessor>());
builder.Services.Replace(ServiceDescriptor.Singleton<IUserIdProvider, SubClaimUserIdProvider>());
builder.Services.AddSignalR().AddJsonProtocol();

var app = builder.Build();

Expand Down Expand Up @@ -83,9 +88,11 @@
// deployments leave the flag false → endpoints are never mounted.
if (builder.Configuration.GetValue<bool>("Auth:DevMode"))
{
app.MapDevAuthEndpoints();
app.MapDevAuthEndpoints();
}

app.MapHub<NotificationsHub>("/hubs/notifications");

app.MapProfileEndpoints();
app.MapAuthEndpoints(CCE.Application.Identity.Auth.Common.LocalAuthApi.External);
app.MapNotificationsEndpoints();
Expand Down
8 changes: 8 additions & 0 deletions backend/src/CCE.Api.External/appsettings.Development.json
Original file line number Diff line number Diff line change
Expand Up @@ -88,5 +88,13 @@
},
"Seq": {
"ServerUrl": "http://localhost:5341"
},
"Messaging": {
"Transport": "InMemory",
"UseAsyncDispatcher": true
// For RabbitMQ production:
// "Transport": "RabbitMQ",
// "RabbitMqHost": "amqp://guest:guest@localhost",
// "RabbitMqVirtualHost": "/cce-dev"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
using CCE.Domain.Notifications;

namespace CCE.Api.Internal.Endpoints;

public sealed record CreateNotificationTemplateRequest(
string Code,
string SubjectAr,
string SubjectEn,
string BodyAr,
string BodyEn,
NotificationChannel Channel,
string VariableSchemaJson);
55 changes: 55 additions & 0 deletions backend/src/CCE.Api.Internal/Endpoints/NotificationLogEndpoints.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
using CCE.Api.Common.Extensions;
using CCE.Application.Notifications.Admin.Commands.RetryNotificationLog;
using CCE.Application.Notifications.Admin.Queries.GetNotificationLogById;
using CCE.Application.Notifications.Admin.Queries.ListNotificationLogs;
using CCE.Domain;
using MediatR;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;

namespace CCE.Api.Internal.Endpoints;

public static class NotificationLogEndpoints
{
public static IEndpointRouteBuilder MapNotificationLogEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/api/admin/notification-logs")
.WithTags("Notification Logs")
.RequireAuthorization(Permissions.Notification_LogView);

group.MapGet("", async (
int? page, int? pageSize,
Guid? recipientUserId, string? templateCode, int? channel, int? status,
IMediator mediator, CancellationToken ct) =>
{
var query = new ListNotificationLogsQuery(
page ?? 1,
pageSize ?? 20,
recipientUserId,
templateCode,
channel is { } c ? (CCE.Domain.Notifications.NotificationChannel)c : null,
status is { } s ? (CCE.Domain.Notifications.NotificationDeliveryStatus)s : null);
var result = await mediator.Send(query, ct).ConfigureAwait(false);
return result.ToHttpResult();
}).WithName("ListNotificationLogs");

group.MapGet("/{id:guid}", async (
Guid id,
IMediator mediator, CancellationToken ct) =>
{
var result = await mediator.Send(new GetNotificationLogByIdQuery(id), ct).ConfigureAwait(false);
return result.ToHttpResult();
}).WithName("GetNotificationLogById");

group.MapPost("/{id:guid}/retry", async (
Guid id,
IMediator mediator, CancellationToken ct) =>
{
var result = await mediator.Send(new RetryNotificationLogCommand(id), ct).ConfigureAwait(false);
return result.ToHttpResult();
}).WithName("RetryNotificationLog");

return app;
}
}
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using CCE.Api.Common.Extensions;
using CCE.Application.Notifications.Commands.CreateNotificationTemplate;
using CCE.Application.Notifications.Commands.UpdateNotificationTemplate;
using CCE.Application.Notifications.Queries.GetNotificationTemplateById;
Expand Down Expand Up @@ -28,7 +29,7 @@ public static IEndpointRouteBuilder MapNotificationTemplateEndpoints(this IEndpo
Channel: channel,
IsActive: isActive);
var result = await mediator.Send(query, cancellationToken).ConfigureAwait(false);
return Results.Ok(result);
return result.ToHttpResult();
})
.RequireAuthorization(Permissions.Notification_TemplateManage)
.WithName("ListNotificationTemplates");
Expand All @@ -37,8 +38,8 @@ public static IEndpointRouteBuilder MapNotificationTemplateEndpoints(this IEndpo
System.Guid id,
IMediator mediator, CancellationToken cancellationToken) =>
{
var dto = await mediator.Send(new GetNotificationTemplateByIdQuery(id), cancellationToken).ConfigureAwait(false);
return dto is null ? Results.NotFound() : Results.Ok(dto);
var result = await mediator.Send(new GetNotificationTemplateByIdQuery(id), cancellationToken).ConfigureAwait(false);
return result.ToHttpResult();
})
.RequireAuthorization(Permissions.Notification_TemplateManage)
.WithName("GetNotificationTemplateById");
Expand All @@ -53,8 +54,8 @@ public static IEndpointRouteBuilder MapNotificationTemplateEndpoints(this IEndpo
body.BodyAr, body.BodyEn,
body.Channel,
body.VariableSchemaJson);
var dto = await mediator.Send(cmd, cancellationToken).ConfigureAwait(false);
return Results.Created($"/api/admin/notification-templates/{dto.Id}", dto);
var result = await mediator.Send(cmd, cancellationToken).ConfigureAwait(false);
return result.ToCreatedHttpResult();
})
.RequireAuthorization(Permissions.Notification_TemplateManage)
.WithName("CreateNotificationTemplate");
Expand All @@ -69,28 +70,12 @@ public static IEndpointRouteBuilder MapNotificationTemplateEndpoints(this IEndpo
body.SubjectAr, body.SubjectEn,
body.BodyAr, body.BodyEn,
body.IsActive);
var dto = await mediator.Send(cmd, cancellationToken).ConfigureAwait(false);
return dto is null ? Results.NotFound() : Results.Ok(dto);
var result = await mediator.Send(cmd, cancellationToken).ConfigureAwait(false);
return result.ToHttpResult();
})
.RequireAuthorization(Permissions.Notification_TemplateManage)
.WithName("UpdateNotificationTemplate");

return app;
}
}

public sealed record CreateNotificationTemplateRequest(
string Code,
string SubjectAr,
string SubjectEn,
string BodyAr,
string BodyEn,
CCE.Domain.Notifications.NotificationChannel Channel,
string VariableSchemaJson);

public sealed record UpdateNotificationTemplateRequest(
string SubjectAr,
string SubjectEn,
string BodyAr,
string BodyEn,
bool IsActive);
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
namespace CCE.Api.Internal.Endpoints;

public sealed record UpdateNotificationTemplateRequest(
string SubjectAr,
string SubjectEn,
string BodyAr,
string BodyEn,
bool IsActive);
2 changes: 2 additions & 0 deletions backend/src/CCE.Api.Internal/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
builder.Services.AddHttpContextAccessor();
builder.Services.Replace(ServiceDescriptor.Scoped<ICurrentUserAccessor, HttpContextCurrentUserAccessor>());
builder.Services.Replace(ServiceDescriptor.Scoped<ICountryScopeAccessor, HttpContextCountryScopeAccessor>());
builder.Services.AddSignalR().AddJsonProtocol();

var app = builder.Build();

Expand Down Expand Up @@ -78,6 +79,7 @@
app.MapTopicEndpoints();
app.MapCommunityModerationEndpoints();
app.MapNotificationTemplateEndpoints();
app.MapNotificationLogEndpoints();
app.MapReportEndpoints();
app.MapAuditEndpoints();
app.MapHomepageSettingsEndpoints();
Expand Down
4 changes: 4 additions & 0 deletions backend/src/CCE.Api.Internal/appsettings.Development.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@
"GraphTenantDomain": "cce.local",
"CallbackPath": "/signin-oidc"
},
"Messaging": {
"Transport": "InMemory",
"UseAsyncDispatcher": true
},
"LocalAuth": {
"External": {
"Issuer": "cce-api-external-dev",
Expand Down
Loading