Skip to content

Commit cefe2cb

Browse files
feat: add notification bell, preferences page, and admin management
Phase 2 of the notifications system: - Notification bell in top navbar with unread count badge and dropdown - AJAX polling every 60s for unread count - Load recent notifications on dropdown open - Mark as read (individual + all) with anti-forgery tokens - Profile/Notifications self-service preferences page - Checkbox matrix: notification types channels (InSite/Email) - Greyed-out unsupported channels, opt-in defaults - Profile/AllNotifications full paginated notifications list - Admin User management: ManageNotifications page - View any user's notification history - Edit any user's notification preferences - NotificationsApiController for bell AJAX endpoints - Updated NuGet: XtremeIdiots.Portal.Repository.Api.Client.V1 2.1.165 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent d42a868 commit cefe2cb

19 files changed

Lines changed: 1230 additions & 7 deletions

src/XtremeIdiots.Portal.Integrations.Forums/XtremeIdiots.Portal.Integrations.Forums.csproj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
<PackageReference Include="Azure.Identity" Version="1.17.1" />
1313
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="10.0.3" />
1414
<PackageReference Include="MX.InvisionCommunity.Api.Client" Version="1.0.18" />
15-
<PackageReference Include="XtremeIdiots.Portal.Repository.Api.Client.V1" Version="2.1.135" />
15+
<PackageReference Include="XtremeIdiots.Portal.Repository.Api.Client.V1" Version="2.1.165" />
1616
</ItemGroup>
1717

1818
</Project>
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
using Microsoft.ApplicationInsights;
2+
using Microsoft.AspNetCore.Authorization;
3+
using Microsoft.AspNetCore.Mvc;
4+
5+
using XtremeIdiots.Portal.Repository.Abstractions.Constants.V1;
6+
using XtremeIdiots.Portal.Repository.Api.Client.V1;
7+
using XtremeIdiots.Portal.Web.Extensions;
8+
9+
namespace XtremeIdiots.Portal.Web.ApiControllers;
10+
11+
/// <summary>
12+
/// API controller for notification operations (bell dropdown)
13+
/// </summary>
14+
[Authorize]
15+
[Route("api/notifications")]
16+
public class NotificationsApiController(
17+
IRepositoryApiClient repositoryApiClient,
18+
TelemetryClient telemetryClient,
19+
ILogger<NotificationsApiController> logger,
20+
IConfiguration configuration) : BaseApiController(telemetryClient, logger, configuration)
21+
{
22+
/// <summary>
23+
/// Gets the unread notification count for the current user
24+
/// </summary>
25+
[HttpGet("unread-count")]
26+
public async Task<IActionResult> GetUnreadCount(CancellationToken cancellationToken = default)
27+
{
28+
return await ExecuteWithErrorHandlingAsync(async () =>
29+
{
30+
var userProfileId = User.UserProfileId();
31+
if (string.IsNullOrEmpty(userProfileId))
32+
{
33+
return Ok(new { count = 0 });
34+
}
35+
36+
var response = await repositoryApiClient.Notifications.V1
37+
.GetUnreadNotificationCount(Guid.Parse(userProfileId), cancellationToken)
38+
.ConfigureAwait(false);
39+
40+
var count = response.Result?.Data ?? 0;
41+
42+
return Ok(new { count });
43+
}, nameof(GetUnreadCount)).ConfigureAwait(false);
44+
}
45+
46+
/// <summary>
47+
/// Gets the most recent notifications for the current user
48+
/// </summary>
49+
/// <param name="take">Number of notifications to return (default 10)</param>
50+
/// <param name="cancellationToken">Cancellation token</param>
51+
[HttpGet("recent")]
52+
public async Task<IActionResult> GetRecent([FromQuery] int take = 10, CancellationToken cancellationToken = default)
53+
{
54+
return await ExecuteWithErrorHandlingAsync(async () =>
55+
{
56+
var userProfileId = User.UserProfileId();
57+
if (string.IsNullOrEmpty(userProfileId))
58+
{
59+
return Ok(Array.Empty<object>());
60+
}
61+
62+
var response = await repositoryApiClient.Notifications.V1
63+
.GetNotifications(Guid.Parse(userProfileId), null, 0, take, NotificationOrder.CreatedAtDesc, cancellationToken)
64+
.ConfigureAwait(false);
65+
66+
if (response.Result?.Data?.Items is null)
67+
{
68+
return Ok(Array.Empty<object>());
69+
}
70+
71+
var notifications = response.Result.Data.Items.Select(n => new
72+
{
73+
n.NotificationId,
74+
n.Title,
75+
n.Message,
76+
n.ActionUrl,
77+
n.IsRead,
78+
n.CreatedAt
79+
});
80+
81+
return Ok(notifications);
82+
}, nameof(GetRecent)).ConfigureAwait(false);
83+
}
84+
85+
/// <summary>
86+
/// Marks a single notification as read
87+
/// </summary>
88+
/// <param name="id">Notification ID</param>
89+
/// <param name="cancellationToken">Cancellation token</param>
90+
[HttpPost("{id}/read")]
91+
[ValidateAntiForgeryToken]
92+
public async Task<IActionResult> MarkAsRead(Guid id, CancellationToken cancellationToken = default)
93+
{
94+
return await ExecuteWithErrorHandlingAsync(async () =>
95+
{
96+
var userProfileId = User.UserProfileId();
97+
if (string.IsNullOrEmpty(userProfileId))
98+
{
99+
return Unauthorized();
100+
}
101+
102+
await repositoryApiClient.Notifications.V1
103+
.MarkNotificationAsRead(id, cancellationToken)
104+
.ConfigureAwait(false);
105+
106+
TrackSuccessTelemetry("NotificationMarkedAsRead", nameof(MarkAsRead), new Dictionary<string, string>
107+
{
108+
{ "NotificationId", id.ToString() }
109+
});
110+
111+
return Ok();
112+
}, nameof(MarkAsRead)).ConfigureAwait(false);
113+
}
114+
115+
/// <summary>
116+
/// Marks all notifications as read for the current user
117+
/// </summary>
118+
/// <param name="cancellationToken">Cancellation token</param>
119+
[HttpPost("read-all")]
120+
[ValidateAntiForgeryToken]
121+
public async Task<IActionResult> MarkAllAsRead(CancellationToken cancellationToken = default)
122+
{
123+
return await ExecuteWithErrorHandlingAsync(async () =>
124+
{
125+
var userProfileId = User.UserProfileId();
126+
if (string.IsNullOrEmpty(userProfileId))
127+
{
128+
return Unauthorized();
129+
}
130+
131+
await repositoryApiClient.Notifications.V1
132+
.MarkAllNotificationsAsRead(Guid.Parse(userProfileId), cancellationToken)
133+
.ConfigureAwait(false);
134+
135+
TrackSuccessTelemetry("AllNotificationsMarkedAsRead", nameof(MarkAllAsRead));
136+
137+
return Ok();
138+
}, nameof(MarkAllAsRead)).ConfigureAwait(false);
139+
}
140+
}

src/XtremeIdiots.Portal.Web/Controllers/ProfileController.cs

Lines changed: 169 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,28 +2,31 @@
22
using Microsoft.AspNetCore.Authorization;
33
using Microsoft.AspNetCore.Mvc;
44

5+
using XtremeIdiots.Portal.Repository.Abstractions.Models.V1.Notifications;
56
using XtremeIdiots.Portal.Repository.Api.Client.V1;
67
using XtremeIdiots.Portal.Web.Auth.Constants;
8+
using XtremeIdiots.Portal.Web.Extensions;
9+
using XtremeIdiots.Portal.Web.Models;
710

811
namespace XtremeIdiots.Portal.Web.Controllers;
912

1013
/// <summary>
11-
/// Handles user profile management operations
14+
/// Handles user profile management operations including notification preferences
1215
/// </summary>
1316
/// <remarks>
1417
/// Initializes a new instance of the ProfileController
1518
/// </remarks>
19+
/// <param name="repositoryApiClient">Client for repository API operations</param>
1620
/// <param name="telemetryClient">Application Insights telemetry client</param>
1721
/// <param name="logger">Logger instance for this controller</param>
1822
/// <param name="configuration">Application configuration</param>
1923
[Authorize(Policy = AuthPolicies.AccessProfile)]
2024
public class ProfileController(
25+
IRepositoryApiClient repositoryApiClient,
2126
TelemetryClient telemetryClient,
2227
ILogger<ProfileController> logger,
2328
IConfiguration configuration) : BaseController(telemetryClient, logger, configuration)
2429
{
25-
// No additional dependencies required for current actions
26-
2730
/// <summary>
2831
/// Displays the user profile management page
2932
/// </summary>
@@ -34,4 +37,167 @@ public async Task<IActionResult> Manage(CancellationToken cancellationToken = de
3437
{
3538
return await ExecuteWithErrorHandlingAsync(() => Task.FromResult<IActionResult>(View()), nameof(Manage)).ConfigureAwait(false);
3639
}
40+
41+
/// <summary>
42+
/// Displays the notification preferences page where users can configure delivery channels
43+
/// </summary>
44+
/// <param name="cancellationToken">Cancellation token for the async operation</param>
45+
/// <returns>The notification preferences view</returns>
46+
[HttpGet]
47+
public async Task<IActionResult> Notifications(CancellationToken cancellationToken = default)
48+
{
49+
return await ExecuteWithErrorHandlingAsync(async () =>
50+
{
51+
var xtremeIdiotsId = User.XtremeIdiotsId();
52+
if (string.IsNullOrEmpty(xtremeIdiotsId))
53+
return RedirectToAction(nameof(Manage));
54+
55+
var userProfileResponse = await repositoryApiClient.UserProfiles.V1
56+
.GetUserProfileByXtremeIdiotsId(xtremeIdiotsId).ConfigureAwait(false);
57+
58+
if (userProfileResponse.IsNotFound || userProfileResponse.Result?.Data is null)
59+
return RedirectToAction(nameof(Manage));
60+
61+
var userProfile = userProfileResponse.Result.Data;
62+
63+
var typesResponse = await repositoryApiClient.NotificationTypes.V1
64+
.GetNotificationTypes(cancellationToken).ConfigureAwait(false);
65+
66+
var prefsResponse = await repositoryApiClient.NotificationPreferences.V1
67+
.GetNotificationPreferences(userProfile.UserProfileId, cancellationToken).ConfigureAwait(false);
68+
69+
var notificationTypes = (typesResponse.Result?.Data?.Items ?? [])
70+
.Select(t => new NotificationTypeViewModel(
71+
t.NotificationTypeId, t.DisplayName, t.Description,
72+
t.SupportsInSite, t.SupportsEmail,
73+
(t.DefaultChannels ?? "").Contains("InSite"),
74+
(t.DefaultChannels ?? "").Contains("Email")))
75+
.ToList();
76+
77+
var preferences = (prefsResponse.Result?.Data?.Items ?? [])
78+
.Select(p => new NotificationPreferenceViewModel(
79+
p.NotificationTypeId, p.InSiteEnabled, p.EmailEnabled))
80+
.ToList();
81+
82+
var model = new NotificationPreferencesPageViewModel(
83+
userProfile.UserProfileId,
84+
notificationTypes,
85+
preferences);
86+
87+
return View(model);
88+
}, nameof(Notifications)).ConfigureAwait(false);
89+
}
90+
91+
/// <summary>
92+
/// Saves notification preferences submitted from the preferences form
93+
/// </summary>
94+
/// <param name="userProfileId">The user profile ID to save preferences for</param>
95+
/// <param name="cancellationToken">Cancellation token for the async operation</param>
96+
/// <returns>Redirects back to the notification preferences page</returns>
97+
[HttpPost]
98+
[ValidateAntiForgeryToken]
99+
public async Task<IActionResult> Notifications(Guid userProfileId, CancellationToken cancellationToken = default)
100+
{
101+
return await ExecuteWithErrorHandlingAsync(async () =>
102+
{
103+
var form = await Request.ReadFormAsync(cancellationToken).ConfigureAwait(false);
104+
var typeIds = form.Keys
105+
.Where(k => k.StartsWith("insite_", StringComparison.Ordinal) || k.StartsWith("email_", StringComparison.Ordinal))
106+
.Select(k => k.Split('_', 2)[1])
107+
.Distinct()
108+
.ToList();
109+
110+
var preferences = new List<NotificationPreferenceViewModel>();
111+
112+
foreach (var typeId in typeIds)
113+
{
114+
preferences.Add(new NotificationPreferenceViewModel(
115+
typeId,
116+
InSiteEnabled: form.ContainsKey($"insite_{typeId}"),
117+
EmailEnabled: form.ContainsKey($"email_{typeId}")));
118+
}
119+
120+
// Handle notification types where both checkboxes are unchecked (not present in form)
121+
var allTypesResponse = await repositoryApiClient.NotificationTypes.V1
122+
.GetNotificationTypes(cancellationToken).ConfigureAwait(false);
123+
var allTypeIds = (allTypesResponse.Result?.Data?.Items ?? []).Select(t => t.NotificationTypeId);
124+
125+
foreach (var typeId in allTypeIds)
126+
{
127+
if (!typeIds.Contains(typeId))
128+
{
129+
preferences.Add(new NotificationPreferenceViewModel(
130+
typeId,
131+
InSiteEnabled: false,
132+
EmailEnabled: false));
133+
}
134+
}
135+
136+
var editDtos = preferences.Select(p => new EditNotificationPreferenceDto(p.NotificationTypeId)
137+
{
138+
InSiteEnabled = p.InSiteEnabled,
139+
EmailEnabled = p.EmailEnabled
140+
}).ToList();
141+
142+
await repositoryApiClient.NotificationPreferences.V1
143+
.UpdateNotificationPreferences(userProfileId, editDtos, cancellationToken).ConfigureAwait(false);
144+
145+
this.AddAlertSuccess("Notification preferences saved successfully.");
146+
return RedirectToAction(nameof(Notifications));
147+
}, nameof(Notifications)).ConfigureAwait(false);
148+
}
149+
150+
/// <summary>
151+
/// Displays all notifications for the current user with pagination
152+
/// </summary>
153+
/// <param name="page">Page number (1-based)</param>
154+
/// <param name="cancellationToken">Cancellation token for the async operation</param>
155+
/// <returns>The all notifications view</returns>
156+
[HttpGet]
157+
public async Task<IActionResult> AllNotifications(int page = 1, CancellationToken cancellationToken = default)
158+
{
159+
return await ExecuteWithErrorHandlingAsync(async () =>
160+
{
161+
var xtremeIdiotsId = User.XtremeIdiotsId();
162+
if (string.IsNullOrEmpty(xtremeIdiotsId))
163+
return RedirectToAction(nameof(Manage));
164+
165+
var userProfileResponse = await repositoryApiClient.UserProfiles.V1
166+
.GetUserProfileByXtremeIdiotsId(xtremeIdiotsId).ConfigureAwait(false);
167+
168+
if (userProfileResponse.IsNotFound || userProfileResponse.Result?.Data is null)
169+
return RedirectToAction(nameof(Manage));
170+
171+
const int pageSize = 20;
172+
var skipEntries = (Math.Max(1, page) - 1) * pageSize;
173+
var userProfileId = userProfileResponse.Result.Data.UserProfileId;
174+
175+
var notificationsResponse = await repositoryApiClient.Notifications.V1
176+
.GetNotifications(userProfileId, null, skipEntries, pageSize, null, cancellationToken).ConfigureAwait(false);
177+
178+
var unreadCountResponse = await repositoryApiClient.Notifications.V1
179+
.GetUnreadNotificationCount(userProfileId, cancellationToken).ConfigureAwait(false);
180+
181+
var items = notificationsResponse.Result?.Data?.Items ?? [];
182+
var totalCount = notificationsResponse.Result?.Pagination?.TotalCount ?? 0;
183+
var unreadCount = unreadCountResponse.Result?.Data ?? 0;
184+
var totalPages = Math.Max(1, (int)Math.Ceiling((double)totalCount / pageSize));
185+
186+
var notifications = items.Select(n => new NotificationViewModel(
187+
n.NotificationId, n.Title, n.Message,
188+
"fa-solid fa-bell",
189+
n.CreatedAt, n.IsRead,
190+
n.ActionUrl)).ToList();
191+
192+
var model = new AllNotificationsPageViewModel(
193+
notifications,
194+
CurrentPage: Math.Max(1, page),
195+
TotalPages: totalPages,
196+
TotalCount: totalCount,
197+
UnreadCount: unreadCount);
198+
199+
return View(model);
200+
}, nameof(AllNotifications)).ConfigureAwait(false);
201+
}
202+
37203
}

0 commit comments

Comments
 (0)