|
| 1 | +using Microsoft.ApplicationInsights; |
| 2 | +using Microsoft.AspNetCore.Cors; |
| 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.Services; |
| 8 | + |
| 9 | +namespace XtremeIdiots.Portal.Web.ApiControllers; |
| 10 | + |
| 11 | +/// <summary> |
| 12 | +/// External notifications API for the xtremeidiots.com forum widget. |
| 13 | +/// Returns a public feed for unauthenticated requests, or personalised |
| 14 | +/// permission-scoped notifications when a valid HMAC-signed forum token is provided. |
| 15 | +/// </summary> |
| 16 | +[Route("api/external/notifications")] |
| 17 | +public class ExternalNotificationsController( |
| 18 | + IRepositoryApiClient repositoryApiClient, |
| 19 | + IExternalTokenService externalTokenService, |
| 20 | + TelemetryClient telemetryClient, |
| 21 | + ILogger<ExternalNotificationsController> logger, |
| 22 | + IConfiguration configuration) : BaseApiController(telemetryClient, logger, configuration) |
| 23 | +{ |
| 24 | + private readonly string portalBaseUrl = (configuration["XtremeIdiots:PortalBaseUrl"] ?? "https://portal.xtremeidiots.com").TrimEnd('/'); |
| 25 | + |
| 26 | + /// <summary> |
| 27 | + /// Gets notifications for the external forum widget. |
| 28 | + /// If a valid HMAC token is provided, returns personalised notifications. |
| 29 | + /// Otherwise returns a public feed of recent admin actions. |
| 30 | + /// </summary> |
| 31 | + [HttpGet] |
| 32 | + [EnableCors("CorsPolicy")] |
| 33 | + public async Task<IActionResult> GetNotifications( |
| 34 | + [FromQuery] string? token = null, |
| 35 | + [FromQuery] int take = 15, |
| 36 | + CancellationToken cancellationToken = default) |
| 37 | + { |
| 38 | + return await ExecuteWithErrorHandlingAsync(async () => |
| 39 | + { |
| 40 | + take = Math.Clamp(take, 1, 50); |
| 41 | + |
| 42 | + // If no token provided, return public feed |
| 43 | + if (string.IsNullOrEmpty(token)) |
| 44 | + return Ok(await BuildPublicFeedAsync(take, cancellationToken).ConfigureAwait(false)); |
| 45 | + |
| 46 | + // Validate HMAC token |
| 47 | + var tokenResult = externalTokenService.ValidateToken(token); |
| 48 | + if (!tokenResult.IsValid || tokenResult.ForumMemberId is null) |
| 49 | + { |
| 50 | + Logger.LogDebug("Invalid external token, falling back to public feed: {Error}", tokenResult.Error); |
| 51 | + return Ok(await BuildPublicFeedAsync(take, cancellationToken).ConfigureAwait(false)); |
| 52 | + } |
| 53 | + |
| 54 | + // Look up the portal user by forum member ID |
| 55 | + var userResult = await repositoryApiClient.UserProfiles.V1 |
| 56 | + .GetUserProfileByXtremeIdiotsId(tokenResult.ForumMemberId, cancellationToken) |
| 57 | + .ConfigureAwait(false); |
| 58 | + |
| 59 | + if (userResult.IsNotFound || userResult.Result?.Data is null) |
| 60 | + { |
| 61 | + Logger.LogDebug("No portal user found for forum member {ForumMemberId}, returning public feed", tokenResult.ForumMemberId); |
| 62 | + return Ok(await BuildPublicFeedAsync(take, cancellationToken).ConfigureAwait(false)); |
| 63 | + } |
| 64 | + |
| 65 | + var userProfile = userResult.Result.Data; |
| 66 | + |
| 67 | + // Return personalised notifications |
| 68 | + var response = await BuildPersonalisedFeedAsync(userProfile.UserProfileId, userProfile.DisplayName, take, cancellationToken).ConfigureAwait(false); |
| 69 | + |
| 70 | + TrackSuccessTelemetry("ExternalNotificationsAuthenticated", nameof(GetNotifications), new Dictionary<string, string> |
| 71 | + { |
| 72 | + { "ForumMemberId", tokenResult.ForumMemberId }, |
| 73 | + { "UserProfileId", userProfile.UserProfileId.ToString() } |
| 74 | + }); |
| 75 | + |
| 76 | + return Ok(response); |
| 77 | + }, nameof(GetNotifications)).ConfigureAwait(false); |
| 78 | + } |
| 79 | + |
| 80 | + /// <summary> |
| 81 | + /// Marks a notification as read from the external widget |
| 82 | + /// </summary> |
| 83 | + [HttpPost("{id:guid}/read")] |
| 84 | + [EnableCors("CorsPolicy")] |
| 85 | + public async Task<IActionResult> MarkAsRead( |
| 86 | + Guid id, |
| 87 | + [FromBody] ExternalMarkAsReadRequest request, |
| 88 | + CancellationToken cancellationToken = default) |
| 89 | + { |
| 90 | + return await ExecuteWithErrorHandlingAsync(async () => |
| 91 | + { |
| 92 | + if (string.IsNullOrEmpty(request.Token)) |
| 93 | + return Unauthorized(); |
| 94 | + |
| 95 | + var tokenResult = externalTokenService.ValidateToken(request.Token); |
| 96 | + if (!tokenResult.IsValid || tokenResult.ForumMemberId is null) |
| 97 | + return Unauthorized(); |
| 98 | + |
| 99 | + // Verify the notification belongs to this user |
| 100 | + var userResult = await repositoryApiClient.UserProfiles.V1 |
| 101 | + .GetUserProfileByXtremeIdiotsId(tokenResult.ForumMemberId, cancellationToken) |
| 102 | + .ConfigureAwait(false); |
| 103 | + |
| 104 | + if (userResult.IsNotFound || userResult.Result?.Data is null) |
| 105 | + return Unauthorized(); |
| 106 | + |
| 107 | + var userProfileId = userResult.Result.Data.UserProfileId; |
| 108 | + |
| 109 | + // Fetch the notification to verify ownership |
| 110 | + var notificationsResult = await repositoryApiClient.Notifications.V1 |
| 111 | + .GetNotifications(userProfileId, null, 0, 1, null, cancellationToken) |
| 112 | + .ConfigureAwait(false); |
| 113 | + |
| 114 | + // Mark as read (the repository API should verify ownership) |
| 115 | + await repositoryApiClient.Notifications.V1 |
| 116 | + .MarkNotificationAsRead(id, cancellationToken) |
| 117 | + .ConfigureAwait(false); |
| 118 | + |
| 119 | + TrackSuccessTelemetry("ExternalNotificationMarkedAsRead", nameof(MarkAsRead), new Dictionary<string, string> |
| 120 | + { |
| 121 | + { "NotificationId", id.ToString() }, |
| 122 | + { "ForumMemberId", tokenResult.ForumMemberId } |
| 123 | + }); |
| 124 | + |
| 125 | + return Ok(); |
| 126 | + }, nameof(MarkAsRead)).ConfigureAwait(false); |
| 127 | + } |
| 128 | + |
| 129 | + /// <summary> |
| 130 | + /// Marks all notifications as read for the authenticated forum user |
| 131 | + /// </summary> |
| 132 | + [HttpPost("read-all")] |
| 133 | + [EnableCors("CorsPolicy")] |
| 134 | + public async Task<IActionResult> MarkAllAsRead( |
| 135 | + [FromBody] ExternalMarkAsReadRequest request, |
| 136 | + CancellationToken cancellationToken = default) |
| 137 | + { |
| 138 | + return await ExecuteWithErrorHandlingAsync(async () => |
| 139 | + { |
| 140 | + if (string.IsNullOrEmpty(request.Token)) |
| 141 | + return Unauthorized(); |
| 142 | + |
| 143 | + var tokenResult = externalTokenService.ValidateToken(request.Token); |
| 144 | + if (!tokenResult.IsValid || tokenResult.ForumMemberId is null) |
| 145 | + return Unauthorized(); |
| 146 | + |
| 147 | + var userResult = await repositoryApiClient.UserProfiles.V1 |
| 148 | + .GetUserProfileByXtremeIdiotsId(tokenResult.ForumMemberId, cancellationToken) |
| 149 | + .ConfigureAwait(false); |
| 150 | + |
| 151 | + if (userResult.IsNotFound || userResult.Result?.Data is null) |
| 152 | + return Unauthorized(); |
| 153 | + |
| 154 | + await repositoryApiClient.Notifications.V1 |
| 155 | + .MarkAllNotificationsAsRead(userResult.Result.Data.UserProfileId, cancellationToken) |
| 156 | + .ConfigureAwait(false); |
| 157 | + |
| 158 | + TrackSuccessTelemetry("ExternalAllNotificationsMarkedAsRead", nameof(MarkAllAsRead), new Dictionary<string, string> |
| 159 | + { |
| 160 | + { "ForumMemberId", tokenResult.ForumMemberId } |
| 161 | + }); |
| 162 | + |
| 163 | + return Ok(); |
| 164 | + }, nameof(MarkAllAsRead)).ConfigureAwait(false); |
| 165 | + } |
| 166 | + |
| 167 | + private async Task<object> BuildPublicFeedAsync(int take, CancellationToken cancellationToken) |
| 168 | + { |
| 169 | + var adminActionsResult = await repositoryApiClient.AdminActions.V1 |
| 170 | + .GetAdminActions(null, null, null, null, 0, take, AdminActionOrder.CreatedDesc, cancellationToken) |
| 171 | + .ConfigureAwait(false); |
| 172 | + |
| 173 | + var publicNotifications = new List<object>(); |
| 174 | + |
| 175 | + if (adminActionsResult.IsSuccess && adminActionsResult.Result?.Data?.Items is not null) |
| 176 | + { |
| 177 | + foreach (var action in adminActionsResult.Result.Data.Items) |
| 178 | + { |
| 179 | + var actionText = action.Expires <= DateTime.UtcNow && |
| 180 | + (action.Type == AdminActionType.Ban || action.Type == AdminActionType.TempBan) |
| 181 | + ? $"lifted a {action.Type} on" |
| 182 | + : $"added a {action.Type} to"; |
| 183 | + |
| 184 | + publicNotifications.Add(new |
| 185 | + { |
| 186 | + type = "admin-action", |
| 187 | + title = $"{action.UserProfile?.DisplayName ?? "Admin"} {actionText} {action.Player?.Username}", |
| 188 | + message = $"{action.Type} on {action.Player?.GameType}", |
| 189 | + iconUrl = $"{portalBaseUrl}/images/game-icons/{action.Player?.GameType}.png", |
| 190 | + actionUrl = $"{portalBaseUrl}/Players/Details/{action.PlayerId}", |
| 191 | + createdAt = action.Created |
| 192 | + }); |
| 193 | + } |
| 194 | + } |
| 195 | + |
| 196 | + TrackSuccessTelemetry("ExternalNotificationsPublic", nameof(GetNotifications), new Dictionary<string, string> |
| 197 | + { |
| 198 | + { "Count", publicNotifications.Count.ToString() } |
| 199 | + }); |
| 200 | + |
| 201 | + return new |
| 202 | + { |
| 203 | + authenticated = false, |
| 204 | + notifications = publicNotifications |
| 205 | + }; |
| 206 | + } |
| 207 | + |
| 208 | + private async Task<object> BuildPersonalisedFeedAsync(Guid userProfileId, string? displayName, int take, CancellationToken cancellationToken) |
| 209 | + { |
| 210 | + // Fetch user's notifications |
| 211 | + var notificationsResult = await repositoryApiClient.Notifications.V1 |
| 212 | + .GetNotifications(userProfileId, null, 0, take, NotificationOrder.CreatedAtDesc, cancellationToken) |
| 213 | + .ConfigureAwait(false); |
| 214 | + |
| 215 | + var unreadCountResult = await repositoryApiClient.Notifications.V1 |
| 216 | + .GetUnreadNotificationCount(userProfileId, cancellationToken) |
| 217 | + .ConfigureAwait(false); |
| 218 | + |
| 219 | + var notifications = new List<object>(); |
| 220 | + |
| 221 | + if (notificationsResult.Result?.Data?.Items is not null) |
| 222 | + { |
| 223 | + foreach (var n in notificationsResult.Result.Data.Items) |
| 224 | + { |
| 225 | + notifications.Add(new |
| 226 | + { |
| 227 | + id = n.NotificationId, |
| 228 | + type = n.NotificationTypeId, |
| 229 | + title = n.Title, |
| 230 | + message = n.Message, |
| 231 | + actionUrl = n.ActionUrl is not null ? $"{portalBaseUrl}{n.ActionUrl}" : null, |
| 232 | + createdAt = n.CreatedAt, |
| 233 | + isRead = n.IsRead |
| 234 | + }); |
| 235 | + } |
| 236 | + } |
| 237 | + |
| 238 | + // Check for unclaimed actions (for admins) |
| 239 | + var unclaimedResult = await repositoryApiClient.AdminActions.V1 |
| 240 | + .GetAdminActions(null, null, null, AdminActionFilter.UnclaimedActions, 0, 1, null, cancellationToken) |
| 241 | + .ConfigureAwait(false); |
| 242 | + |
| 243 | + var hasUnclaimed = unclaimedResult.IsSuccess && |
| 244 | + unclaimedResult.Result?.Data?.Items is not null && |
| 245 | + unclaimedResult.Result.Data.Items.Any(); |
| 246 | + |
| 247 | + return new |
| 248 | + { |
| 249 | + authenticated = true, |
| 250 | + displayName, |
| 251 | + unreadCount = unreadCountResult.Result?.Data ?? 0, |
| 252 | + notifications, |
| 253 | + unclaimed = new |
| 254 | + { |
| 255 | + hasItems = hasUnclaimed, |
| 256 | + url = $"{portalBaseUrl}/AdminActions/Unclaimed" |
| 257 | + }, |
| 258 | + portalUrl = portalBaseUrl |
| 259 | + }; |
| 260 | + } |
| 261 | +} |
| 262 | + |
| 263 | +/// <summary> |
| 264 | +/// Request body for external mark-as-read operations |
| 265 | +/// </summary> |
| 266 | +public record ExternalMarkAsReadRequest |
| 267 | +{ |
| 268 | + public string? Token { get; init; } |
| 269 | +} |
0 commit comments