From 7332ac658c0a95a0397d58bae691cf0f36e158f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Niels=20Pilgaard=20Gr=C3=B8ndahl?= Date: Wed, 31 Dec 2025 00:27:20 +0100 Subject: [PATCH 01/13] improve group invite flow and display --- .../Groups/Events/GroupPostCreated.cs | 12 +++ .../Consumers/GroupPostCreatedConsumer.cs | 101 ++++++++++++++++++ .../Jordnaer/Features/Email/EmailService.cs | 4 +- .../Features/GroupPosts/GroupPostService.cs | 32 +++++- .../Jordnaer/Features/Groups/GroupService.cs | 55 ++++++++++ .../Features/Groups/GroupSummaryCard.razor | 13 +++ .../Features/Posts/PostCardComponent.razor | 4 +- .../Features/Sharing/SocialShareButtons.razor | 2 +- .../Jordnaer/Pages/Groups/GroupDetails.razor | 27 +++++ src/web/Jordnaer/Pages/Groups/MyGroups.razor | 13 ++- src/web/Jordnaer/Pages/Shared/TopBar.razor | 36 ++++++- 11 files changed, 286 insertions(+), 13 deletions(-) create mode 100644 src/shared/Jordnaer.Shared/Groups/Events/GroupPostCreated.cs create mode 100644 src/web/Jordnaer/Consumers/GroupPostCreatedConsumer.cs diff --git a/src/shared/Jordnaer.Shared/Groups/Events/GroupPostCreated.cs b/src/shared/Jordnaer.Shared/Groups/Events/GroupPostCreated.cs new file mode 100644 index 00000000..672c0d91 --- /dev/null +++ b/src/shared/Jordnaer.Shared/Groups/Events/GroupPostCreated.cs @@ -0,0 +1,12 @@ +namespace Jordnaer.Shared; + +public class GroupPostCreated +{ + public required Guid PostId { get; init; } + public required Guid GroupId { get; init; } + public required string GroupName { get; init; } + public required string AuthorId { get; init; } + public required string AuthorDisplayName { get; init; } + public required string PostText { get; init; } + public DateTimeOffset CreatedUtc { get; init; } = DateTimeOffset.UtcNow; +} diff --git a/src/web/Jordnaer/Consumers/GroupPostCreatedConsumer.cs b/src/web/Jordnaer/Consumers/GroupPostCreatedConsumer.cs new file mode 100644 index 00000000..9969ea9e --- /dev/null +++ b/src/web/Jordnaer/Consumers/GroupPostCreatedConsumer.cs @@ -0,0 +1,101 @@ +using Jordnaer.Database; +using Jordnaer.Features.Email; +using Jordnaer.Shared; +using MassTransit; +using Microsoft.AspNetCore.Components; +using Microsoft.EntityFrameworkCore; + +namespace Jordnaer.Consumers; + +public class GroupPostCreatedConsumer( + IDbContextFactory contextFactory, + ILogger logger, + IPublishEndpoint publishEndpoint, + NavigationManager navigationManager) : IConsumer +{ + public async Task Consume(ConsumeContext consumeContext) + { + logger.LogInformation("Consuming GroupPostCreated message. PostId: {PostId}, GroupId: {GroupId}", + consumeContext.Message.PostId, consumeContext.Message.GroupId); + + var message = consumeContext.Message; + + try + { + await using var context = await contextFactory.CreateDbContextAsync(consumeContext.CancellationToken); + + // Get all active members excluding the post author + var activeMembers = context.GroupMemberships + .AsNoTracking() + .Where(x => x.GroupId == message.GroupId && + x.MembershipStatus == MembershipStatus.Active && + x.UserProfileId != message.AuthorId) + .Select(x => x.UserProfileId); + + // Get their email addresses + var emails = await context.Users + .AsNoTracking() + .Where(user => activeMembers.Any(userId => userId == user.Id) && + !string.IsNullOrEmpty(user.Email)) + .Select(user => new EmailRecipient + { + Email = user.Email!, + DisplayName = user.UserName + }) + .ToListAsync(consumeContext.CancellationToken); + + if (emails.Count == 0) + { + logger.LogInformation("No members to notify for new post in group {GroupName}", message.GroupName); + return; + } + + logger.LogInformation("Sending new post notification to {Count} members in group {GroupName}", + emails.Count, message.GroupName); + + var groupUrl = $"{navigationManager.BaseUri}groups/{message.GroupName}"; + var postPreview = GetPostPreview(message.PostText); + + var email = new SendEmail + { + Subject = $"Nyt opslag i {message.GroupName}", + HtmlContent = CreateNewPostEmailContent(message.AuthorDisplayName, postPreview, groupUrl), + Bcc = emails + }; + + await publishEndpoint.Publish(email, consumeContext.CancellationToken); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to send new post notifications for post {PostId} in group {GroupId}", + message.PostId, message.GroupId); + // Don't rethrow - we don't want email failures to break post creation + } + } + + private static string GetPostPreview(string text) + { + // Strip HTML tags and limit to 200 characters + var plainText = System.Text.RegularExpressions.Regex.Replace(text, "<.*?>", string.Empty); + return plainText.Length <= 200 + ? plainText + : plainText.Substring(0, 200) + "..."; + } + + private static string CreateNewPostEmailContent(string authorName, string postPreview, string groupUrl) + { + return $""" +

Nyt opslag i din gruppe

+ +

{authorName} har oprettet et nyt opslag:

+ +
+ {postPreview} +
+ +

Klik her for at se opslaget

+ + {EmailConstants.Signature} + """; + } +} diff --git a/src/web/Jordnaer/Features/Email/EmailService.cs b/src/web/Jordnaer/Features/Email/EmailService.cs index cd394764..b7430058 100644 --- a/src/web/Jordnaer/Features/Email/EmailService.cs +++ b/src/web/Jordnaer/Features/Email/EmailService.cs @@ -75,9 +75,9 @@ public async Task SendMembershipRequestEmails( var email = new SendEmail { - Subject = "Ny medlemskabsanmodning", + Subject = $"Ny medlemskabsanmodning til {groupName}", HtmlContent = $""" -

Din gruppe har modtaget en ny medlemskabsanmodning

+

Din gruppe {groupName} har modtaget en ny medlemskabsanmodning

Klik her for at se den diff --git a/src/web/Jordnaer/Features/GroupPosts/GroupPostService.cs b/src/web/Jordnaer/Features/GroupPosts/GroupPostService.cs index 85893797..93485838 100644 --- a/src/web/Jordnaer/Features/GroupPosts/GroupPostService.cs +++ b/src/web/Jordnaer/Features/GroupPosts/GroupPostService.cs @@ -1,5 +1,6 @@ using Jordnaer.Database; using Jordnaer.Shared; +using MassTransit; using Microsoft.EntityFrameworkCore; using OneOf; using OneOf.Types; @@ -8,7 +9,9 @@ namespace Jordnaer.Features.GroupPosts; -public class GroupPostService(IDbContextFactory contextFactory) +public class GroupPostService( + IDbContextFactory contextFactory, + IPublishEndpoint publishEndpoint) { public async Task> GetPostAsync(Guid postId, CancellationToken cancellationToken = default) @@ -58,6 +61,33 @@ public async Task>> CreatePostAsync(GroupPost post, await context.SaveChangesAsync(cancellationToken); + // Publish event for email notifications - handled out-of-process by MassTransit + var group = await context.Groups + .AsNoTracking() + .Where(g => g.Id == post.GroupId) + .Select(g => new { g.Id, g.Name }) + .FirstOrDefaultAsync(cancellationToken); + + var author = await context.UserProfiles + .AsNoTracking() + .Where(u => u.Id == post.UserProfileId) + .Select(u => new { u.Id, u.DisplayName }) + .FirstOrDefaultAsync(cancellationToken); + + if (group is not null && author is not null) + { + await publishEndpoint.Publish(new GroupPostCreated + { + PostId = post.Id, + GroupId = group.Id, + GroupName = group.Name, + AuthorId = author.Id, + AuthorDisplayName = author.DisplayName, + PostText = post.Text, + CreatedUtc = post.CreatedUtc + }, cancellationToken); + } + return new Success(); } diff --git a/src/web/Jordnaer/Features/Groups/GroupService.cs b/src/web/Jordnaer/Features/Groups/GroupService.cs index b6bc3606..f65bddc7 100644 --- a/src/web/Jordnaer/Features/Groups/GroupService.cs +++ b/src/web/Jordnaer/Features/Groups/GroupService.cs @@ -178,6 +178,61 @@ public async Task> GetGroupMembershipsAsync(string grou return groupMembership; } + /// + /// Gets the count of pending membership requests for a specific group. + /// + public async Task GetPendingMembershipCountAsync(Guid groupId, CancellationToken cancellationToken = default) + { + logger.LogFunctionBegan(); + + await using var context = await contextFactory.CreateDbContextAsync(cancellationToken); + var count = await context.GroupMemberships + .AsNoTracking() + .CountAsync(x => x.GroupId == groupId && + x.MembershipStatus == MembershipStatus.PendingApprovalFromGroup, + cancellationToken); + + return count; + } + + /// + /// Gets pending membership counts for all groups the current user can manage (admin or owner). + /// Returns dictionary mapping GroupId to pending count. + /// + public async Task> GetPendingMembershipCountsForUserAsync(CancellationToken cancellationToken = default) + { + Debug.Assert(currentUser.Id is not null, "Current user must be set when fetching pending counts."); + + logger.LogFunctionBegan(); + + await using var context = await contextFactory.CreateDbContextAsync(cancellationToken); + + // Get all groups where current user is admin or owner + var adminGroupIds = await context.GroupMemberships + .AsNoTracking() + .Where(x => x.UserProfileId == currentUser.Id && + (x.PermissionLevel == PermissionLevel.Admin || + x.OwnershipLevel == OwnershipLevel.Owner)) + .Select(x => x.GroupId) + .ToListAsync(cancellationToken); + + if (adminGroupIds.Count == 0) + { + return new Dictionary(); + } + + // Get pending counts for those groups in a single query + var pendingCounts = await context.GroupMemberships + .AsNoTracking() + .Where(x => adminGroupIds.Contains(x.GroupId) && + x.MembershipStatus == MembershipStatus.PendingApprovalFromGroup) + .GroupBy(x => x.GroupId) + .Select(g => new { GroupId = g.Key, Count = g.Count() }) + .ToDictionaryAsync(x => x.GroupId, x => x.Count, cancellationToken); + + return pendingCounts; + } + public async Task>> UpdateMembership(GroupMembershipDto membershipDto, CancellationToken cancellationToken = default) { Debug.Assert(currentUser.Id is not null, "Current user must be set when updating group membership."); diff --git a/src/web/Jordnaer/Features/Groups/GroupSummaryCard.razor b/src/web/Jordnaer/Features/Groups/GroupSummaryCard.razor index c1b25f37..2d577abc 100644 --- a/src/web/Jordnaer/Features/Groups/GroupSummaryCard.razor +++ b/src/web/Jordnaer/Features/Groups/GroupSummaryCard.razor @@ -3,6 +3,15 @@ @UserGroupAccess.Group.Name.Sanitize() + @if (PendingRequestCount > 0) + { + + 1 ? "er" : ""))" + Icon="@Icons.Material.Filled.PersonAdd" + Color="Color.Warning" /> + + } @if (UserGroupAccess.OwnershipLevel is OwnershipLevel.Owner || UserGroupAccess.PermissionLevel is PermissionLevel.Admin) { @@ -47,6 +56,10 @@ [Parameter] public required UserGroupAccess UserGroupAccess { get; set; } + [Parameter] + public int PendingRequestCount { get; set; } = 0; + private string GroupUrl => $"/groups/{UserGroupAccess.Group.Name}"; private string EditGroupUrl => $"/groups/{UserGroupAccess.Group.Id}/edit"; + private string MembersPageUrl => $"/groups/{UserGroupAccess.Group.Name}/members"; } \ No newline at end of file diff --git a/src/web/Jordnaer/Features/Posts/PostCardComponent.razor b/src/web/Jordnaer/Features/Posts/PostCardComponent.razor index 03557f2d..64f27ba7 100644 --- a/src/web/Jordnaer/Features/Posts/PostCardComponent.razor +++ b/src/web/Jordnaer/Features/Posts/PostCardComponent.razor @@ -43,10 +43,10 @@ - + Del på Facebook - + Del på Bluesky diff --git a/src/web/Jordnaer/Features/Sharing/SocialShareButtons.razor b/src/web/Jordnaer/Features/Sharing/SocialShareButtons.razor index 3d1c933d..baf362bf 100644 --- a/src/web/Jordnaer/Features/Sharing/SocialShareButtons.razor +++ b/src/web/Jordnaer/Features/Sharing/SocialShareButtons.razor @@ -9,7 +9,7 @@ - diff --git a/src/web/Jordnaer/Pages/Groups/GroupDetails.razor b/src/web/Jordnaer/Pages/Groups/GroupDetails.razor index 0067587d..bc90a497 100644 --- a/src/web/Jordnaer/Pages/Groups/GroupDetails.razor +++ b/src/web/Jordnaer/Pages/Groups/GroupDetails.razor @@ -63,6 +63,25 @@ + @* Pending Requests Alert for Admins/Owners *@ + @if (_currentUserIsAdmin && _pendingRequestCount > 0) + { + +
+ + @_pendingRequestCount afventende medlemskabsanmodning@(_pendingRequestCount > 1 ? "er" : "") + + + Administrér medlemmer + +
+
+ } + @@ -219,6 +238,7 @@ private GroupPostList? _groupPostList; private LeafletMap? _leafletMap; private bool _markerAdded; + private int _pendingRequestCount = 0; protected override async Task OnInitializedAsync() { @@ -257,6 +277,13 @@ x.PermissionLevel == PermissionLevel.Admin); _recipients = _admins.Concat([_currentUser.ToUserSlim()]).ToList(); + + // Fetch pending membership count if user is admin + if (_currentUserIsAdmin) + { + _pendingRequestCount = await GroupService.GetPendingMembershipCountAsync(_group.Id); + } + _isLoading = false; } diff --git a/src/web/Jordnaer/Pages/Groups/MyGroups.razor b/src/web/Jordnaer/Pages/Groups/MyGroups.razor index cb615ede..4fc19ddf 100644 --- a/src/web/Jordnaer/Pages/Groups/MyGroups.razor +++ b/src/web/Jordnaer/Pages/Groups/MyGroups.razor @@ -42,7 +42,8 @@ @foreach (var group in _ownedGroups) { - + } @@ -63,7 +64,8 @@ @foreach (var group in _memberGroups) { - + } @@ -86,7 +88,8 @@ @foreach (var group in _pendingAccess) { - + } @@ -111,6 +114,7 @@ private UserGroupAccess[] _ownedGroups = Array.Empty(); private UserGroupAccess[] _memberGroups = Array.Empty(); private UserGroupAccess[] _pendingAccess = Array.Empty(); + private Dictionary _pendingCounts = new(); protected override async Task OnInitializedAsync() { @@ -145,5 +149,8 @@ MembershipStatus.PendingApprovalFromUser) .OrderByDescending(x => x.LastUpdatedUtc) .ToArray(); + + // Fetch pending membership counts for groups user can manage + _pendingCounts = await GroupService.GetPendingMembershipCountsForUserAsync(); } } \ No newline at end of file diff --git a/src/web/Jordnaer/Pages/Shared/TopBar.razor b/src/web/Jordnaer/Pages/Shared/TopBar.razor index 743dab2b..12de53b9 100644 --- a/src/web/Jordnaer/Pages/Shared/TopBar.razor +++ b/src/web/Jordnaer/Pages/Shared/TopBar.razor @@ -1,6 +1,7 @@ @inject NavigationManager Navigation @inject UnreadMessageSignalRClient UnreadMessageSignalRClient @inject IChatService ChatService +@inject GroupService GroupService @inject CurrentUser CurrentUser @inject IJSRuntime JsRuntime @@ -19,10 +20,24 @@ Class="topbar-nav-link font-open-sans-semibold" Style="@JordnaerPalette.BlueBody.ToTextColor()"> Søg - - Grupper - + + + + + + Grupper + + + + + + Grupper + + + Grupper + + + @if (_pendingGroupRequestCount > 0) + { + + } + + @@ -167,6 +190,7 @@ @code { private int _unreadCount = 0; + private int _pendingGroupRequestCount = 0; private bool _isJsAvailable = false; private string? _baseTitle = null; @@ -180,6 +204,10 @@ // Get initial unread count _unreadCount = await ChatService.GetUnreadMessageCountAsync(CurrentUser.Id); + // Get pending group membership requests count + var pendingCounts = await GroupService.GetPendingMembershipCountsForUserAsync(); + _pendingGroupRequestCount = pendingCounts.Values.Sum(); + // Subscribe to real-time updates UnreadMessageSignalRClient.OnMessageReceived(async message => { From 4e20b42dfd25cdb5ccd542b646baff4cc4f6d585 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Niels=20Pilgaard=20Gr=C3=B8ndahl?= Date: Wed, 31 Dec 2025 00:49:18 +0100 Subject: [PATCH 02/13] add signalr notifs --- .../Groups/GroupMembershipStatusChanged.cs | 7 +++ .../Features/Dashboard/GroupsHub.razor | 53 +++++++++++++++++++ .../Features/Groups/GroupMembershipHub.cs | 47 ++++++++++++++++ .../Groups/GroupMembershipSignalRClient.cs | 35 ++++++++++++ .../Jordnaer/Features/Groups/GroupService.cs | 23 +++++++- .../Groups/WebApplicationBuilderExtensions.cs | 1 + src/web/Jordnaer/Pages/Shared/TopBar.razor | 23 +++++++- src/web/Jordnaer/Program.cs | 1 + 8 files changed, 188 insertions(+), 2 deletions(-) create mode 100644 src/shared/Jordnaer.Shared/Groups/GroupMembershipStatusChanged.cs create mode 100644 src/web/Jordnaer/Features/Groups/GroupMembershipHub.cs create mode 100644 src/web/Jordnaer/Features/Groups/GroupMembershipSignalRClient.cs diff --git a/src/shared/Jordnaer.Shared/Groups/GroupMembershipStatusChanged.cs b/src/shared/Jordnaer.Shared/Groups/GroupMembershipStatusChanged.cs new file mode 100644 index 00000000..47216a50 --- /dev/null +++ b/src/shared/Jordnaer.Shared/Groups/GroupMembershipStatusChanged.cs @@ -0,0 +1,7 @@ +namespace Jordnaer.Shared; + +public class GroupMembershipStatusChanged +{ + public required Guid GroupId { get; init; } + public int PendingCountChange { get; init; } +} diff --git a/src/web/Jordnaer/Features/Dashboard/GroupsHub.razor b/src/web/Jordnaer/Features/Dashboard/GroupsHub.razor index 23f5f0b1..9bc4e4c7 100644 --- a/src/web/Jordnaer/Features/Dashboard/GroupsHub.razor +++ b/src/web/Jordnaer/Features/Dashboard/GroupsHub.razor @@ -1,8 +1,11 @@ @page "/groups" @inject GroupService GroupService +@inject GroupMembershipSignalRClient GroupMembershipSignalRClient @inject NavigationManager Navigation +@implements IAsyncDisposable + @@ -13,6 +16,25 @@ Find grupper + @* Pending Requests Alert *@ + @if (_totalPendingRequests > 0) + { + +
+ + @_totalPendingRequests afventende medlemskabsanmodning@(_totalPendingRequests > 1 ? "er" : "") i dine grupper + + + Administrér medlemmer + +
+
+ } + @* Main Hub Cards *@ @@ -138,6 +160,7 @@ @code { private bool _isLoading = true; private List _recentGroups = new(); + private int _totalPendingRequests = 0; protected override async Task OnInitializedAsync() { @@ -147,6 +170,31 @@ } await LoadRecentGroups(); + + // Get pending counts and admin group IDs + var pendingCounts = await GroupService.GetPendingMembershipCountsForUserAsync(); + _totalPendingRequests = pendingCounts.Values.Sum(); + var adminGroupIds = pendingCounts.Keys.ToList(); + + // Subscribe to group membership status changes + GroupMembershipSignalRClient.OnMembershipStatusChanged(async notification => + { + _totalPendingRequests += notification.PendingCountChange; + if (_totalPendingRequests < 0) + { + _totalPendingRequests = 0; + } + await InvokeAsync(StateHasChanged); + }); + + await GroupMembershipSignalRClient.StartAsync(); + + // Join SignalR groups for admin notifications + if (adminGroupIds.Count > 0) + { + await GroupMembershipSignalRClient.JoinAdminGroupsAsync(adminGroupIds); + } + _isLoading = false; } @@ -161,4 +209,9 @@ .Take(5) .ToList(); } + + public async ValueTask DisposeAsync() + { + await GroupMembershipSignalRClient.StopAsync(); + } } \ No newline at end of file diff --git a/src/web/Jordnaer/Features/Groups/GroupMembershipHub.cs b/src/web/Jordnaer/Features/Groups/GroupMembershipHub.cs new file mode 100644 index 00000000..66b27d50 --- /dev/null +++ b/src/web/Jordnaer/Features/Groups/GroupMembershipHub.cs @@ -0,0 +1,47 @@ +using Jordnaer.Shared; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.SignalR; + +namespace Jordnaer.Features.Groups; + +public interface IGroupMembershipHub +{ + Task MembershipStatusChanged(GroupMembershipStatusChanged notification); +} + +[Authorize] +public class GroupMembershipHub(ILogger logger) : Hub +{ + public override async Task OnConnectedAsync() + { + logger.LogDebug("User {userId} connected to {hubName}", Context.User?.GetId(), nameof(GroupMembershipHub)); + + await base.OnConnectedAsync(); + } + + public override async Task OnDisconnectedAsync(Exception? exception) + { + if (exception is not null) + { + logger.LogError(exception, "User {userId} disconnected from {hubName}. " + + "Exception message: {exceptionMessage}", + Context.User?.GetId(), nameof(GroupMembershipHub), exception.Message); + } + else + { + logger.LogDebug("User {userId} disconnected from {hubName}", Context.User?.GetId(), nameof(GroupMembershipHub)); + } + + await base.OnDisconnectedAsync(exception); + } + + public async Task JoinAdminGroups(List groupIds) + { + logger.LogDebug("User {userId} joining {count} admin groups", Context.User?.GetId(), groupIds.Count); + + foreach (var groupId in groupIds) + { + await Groups.AddToGroupAsync(Context.ConnectionId, $"group-admins-{groupId}"); + } + } +} diff --git a/src/web/Jordnaer/Features/Groups/GroupMembershipSignalRClient.cs b/src/web/Jordnaer/Features/Groups/GroupMembershipSignalRClient.cs new file mode 100644 index 00000000..82b9559a --- /dev/null +++ b/src/web/Jordnaer/Features/Groups/GroupMembershipSignalRClient.cs @@ -0,0 +1,35 @@ +using Jordnaer.Features.Authentication; +using Jordnaer.Shared; +using Jordnaer.SignalR; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.SignalR.Client; + +namespace Jordnaer.Features.Groups; + +public class GroupMembershipSignalRClient( + CurrentUser currentUser, + ILogger logger, + NavigationManager navigationManager) + : AuthenticatedSignalRClientBase(logger, currentUser, navigationManager, "/hubs/group-membership") +{ + public void OnMembershipStatusChanged(Func action) + { + if (HubConnection is null) + { + return; + } + + HubConnection.Remove(nameof(IGroupMembershipHub.MembershipStatusChanged)); + HubConnection.On(nameof(IGroupMembershipHub.MembershipStatusChanged), action); + } + + public async Task JoinAdminGroupsAsync(List groupIds) + { + if (HubConnection is null || !IsConnected) + { + return; + } + + await HubConnection.InvokeAsync(nameof(GroupMembershipHub.JoinAdminGroups), groupIds); + } +} diff --git a/src/web/Jordnaer/Features/Groups/GroupService.cs b/src/web/Jordnaer/Features/Groups/GroupService.cs index f65bddc7..913449d4 100644 --- a/src/web/Jordnaer/Features/Groups/GroupService.cs +++ b/src/web/Jordnaer/Features/Groups/GroupService.cs @@ -3,6 +3,7 @@ using Jordnaer.Features.Authentication; using Jordnaer.Features.Metrics; using Jordnaer.Shared; +using Microsoft.AspNetCore.SignalR; using Microsoft.EntityFrameworkCore; using OneOf; using OneOf.Types; @@ -17,7 +18,8 @@ public class GroupService( IDbContextFactory contextFactory, ILogger logger, IDiagnosticContext diagnosticContext, - CurrentUser currentUser) + CurrentUser currentUser, + IHubContext hubContext) { public async Task> GetGroupByIdAsync(Guid id, CancellationToken cancellationToken = default) { @@ -270,6 +272,12 @@ public async Task>> UpdateMembership(GroupMembershi return logger.LogAndReturnErrorResult(error); } + // Track if pending status changed to notify listeners + var oldStatus = membership.MembershipStatus; + var newStatus = membershipDto.MembershipStatus; + var wasPending = oldStatus == MembershipStatus.PendingApprovalFromGroup; + var isPending = newStatus == MembershipStatus.PendingApprovalFromGroup; + membership.OwnershipLevel = membershipDto.OwnershipLevel; membership.PermissionLevel = membershipDto.PermissionLevel; membership.MembershipStatus = membershipDto.MembershipStatus; @@ -280,6 +288,19 @@ public async Task>> UpdateMembership(GroupMembershi try { await context.SaveChangesAsync(cancellationToken); + + // Notify admins via SignalR if pending count changed + if (wasPending != isPending) + { + var pendingCountChange = isPending ? 1 : -1; + await hubContext.Clients + .Group($"group-admins-{membershipDto.GroupId}") + .MembershipStatusChanged(new GroupMembershipStatusChanged + { + GroupId = membershipDto.GroupId, + PendingCountChange = pendingCountChange + }); + } } catch (Exception exception) { diff --git a/src/web/Jordnaer/Features/Groups/WebApplicationBuilderExtensions.cs b/src/web/Jordnaer/Features/Groups/WebApplicationBuilderExtensions.cs index 9daf816a..f1a0bad4 100644 --- a/src/web/Jordnaer/Features/Groups/WebApplicationBuilderExtensions.cs +++ b/src/web/Jordnaer/Features/Groups/WebApplicationBuilderExtensions.cs @@ -5,6 +5,7 @@ public static class WebApplicationBuilderExtensions public static WebApplicationBuilder AddGroupServices(this WebApplicationBuilder builder) { builder.Services.AddScoped(); + builder.Services.AddScoped(); return builder; } diff --git a/src/web/Jordnaer/Pages/Shared/TopBar.razor b/src/web/Jordnaer/Pages/Shared/TopBar.razor index 12de53b9..09a8d93e 100644 --- a/src/web/Jordnaer/Pages/Shared/TopBar.razor +++ b/src/web/Jordnaer/Pages/Shared/TopBar.razor @@ -2,6 +2,7 @@ @inject UnreadMessageSignalRClient UnreadMessageSignalRClient @inject IChatService ChatService @inject GroupService GroupService +@inject GroupMembershipSignalRClient GroupMembershipSignalRClient @inject CurrentUser CurrentUser @inject IJSRuntime JsRuntime @@ -204,9 +205,10 @@ // Get initial unread count _unreadCount = await ChatService.GetUnreadMessageCountAsync(CurrentUser.Id); - // Get pending group membership requests count + // Get pending group membership requests count and admin group IDs var pendingCounts = await GroupService.GetPendingMembershipCountsForUserAsync(); _pendingGroupRequestCount = pendingCounts.Values.Sum(); + var adminGroupIds = pendingCounts.Keys.ToList(); // Subscribe to real-time updates UnreadMessageSignalRClient.OnMessageReceived(async message => @@ -235,7 +237,25 @@ // Subscribe to local messages marked as read event ChatService.MessagesMarkedAsRead += OnMessagesMarkedAsRead; + // Subscribe to group membership status changes + GroupMembershipSignalRClient.OnMembershipStatusChanged(async notification => + { + _pendingGroupRequestCount += notification.PendingCountChange; + if (_pendingGroupRequestCount < 0) + { + _pendingGroupRequestCount = 0; + } + await InvokeAsync(StateHasChanged); + }); + await UnreadMessageSignalRClient.StartAsync(); + await GroupMembershipSignalRClient.StartAsync(); + + // Join SignalR groups for admin notifications + if (adminGroupIds.Count > 0) + { + await GroupMembershipSignalRClient.JoinAdminGroupsAsync(adminGroupIds); + } } protected override async Task OnAfterRenderAsync(bool firstRender) @@ -289,6 +309,7 @@ { ChatService.MessagesMarkedAsRead -= OnMessagesMarkedAsRead; await UnreadMessageSignalRClient.StopAsync(); + await GroupMembershipSignalRClient.StopAsync(); } } diff --git a/src/web/Jordnaer/Program.cs b/src/web/Jordnaer/Program.cs index 4f8ba719..73eead64 100644 --- a/src/web/Jordnaer/Program.cs +++ b/src/web/Jordnaer/Program.cs @@ -157,6 +157,7 @@ app.MapObservabilityEndpoints(); app.MapHub("/hubs/chat"); +app.MapHub("/hubs/group-membership"); app.UseSitemap(); From df534745c064327a2b0cdb6a3c3539ab6b6e5bc0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Niels=20Pilgaard=20Gr=C3=B8ndahl?= Date: Thu, 1 Jan 2026 20:16:37 +0100 Subject: [PATCH 03/13] fix review comments --- .../Consumers/GroupPostCreatedConsumer.cs | 32 +++++++++++++--- .../Features/GroupPosts/GroupPostService.cs | 12 +++++- .../Features/Groups/GroupMembershipHub.cs | 38 +++++++++++++++++-- .../Jordnaer/Features/Groups/GroupService.cs | 25 ++++++++---- .../Features/Metrics/JordnaerMetrics.cs | 7 ++++ 5 files changed, 98 insertions(+), 16 deletions(-) diff --git a/src/web/Jordnaer/Consumers/GroupPostCreatedConsumer.cs b/src/web/Jordnaer/Consumers/GroupPostCreatedConsumer.cs index 9969ea9e..e5c36d39 100644 --- a/src/web/Jordnaer/Consumers/GroupPostCreatedConsumer.cs +++ b/src/web/Jordnaer/Consumers/GroupPostCreatedConsumer.cs @@ -1,13 +1,16 @@ using Jordnaer.Database; using Jordnaer.Features.Email; +using Jordnaer.Features.Metrics; using Jordnaer.Shared; using MassTransit; using Microsoft.AspNetCore.Components; using Microsoft.EntityFrameworkCore; +using System.Net; +using System.Text.RegularExpressions; namespace Jordnaer.Consumers; -public class GroupPostCreatedConsumer( +public partial class GroupPostCreatedConsumer( IDbContextFactory contextFactory, ILogger logger, IPublishEndpoint publishEndpoint, @@ -15,6 +18,8 @@ public class GroupPostCreatedConsumer( { public async Task Consume(ConsumeContext consumeContext) { + JordnaerMetrics.GroupPostCreatedConsumerReceivedCounter.Add(1); + logger.LogInformation("Consuming GroupPostCreated message. PostId: {PostId}, GroupId: {GroupId}", consumeContext.Message.PostId, consumeContext.Message.GroupId); @@ -47,6 +52,7 @@ public async Task Consume(ConsumeContext consumeContext) if (emails.Count == 0) { logger.LogInformation("No members to notify for new post in group {GroupName}", message.GroupName); + JordnaerMetrics.GroupPostCreatedConsumerSucceededCounter.Add(1); return; } @@ -64,9 +70,13 @@ public async Task Consume(ConsumeContext consumeContext) }; await publishEndpoint.Publish(email, consumeContext.CancellationToken); + + JordnaerMetrics.GroupPostCreatedConsumerSucceededCounter.Add(1); } catch (Exception ex) { + JordnaerMetrics.GroupPostCreatedConsumerFailedCounter.Add(1); + logger.LogError(ex, "Failed to send new post notifications for post {PostId} in group {GroupId}", message.PostId, message.GroupId); // Don't rethrow - we don't want email failures to break post creation @@ -76,7 +86,7 @@ public async Task Consume(ConsumeContext consumeContext) private static string GetPostPreview(string text) { // Strip HTML tags and limit to 200 characters - var plainText = System.Text.RegularExpressions.Regex.Replace(text, "<.*?>", string.Empty); + var plainText = HtmlTagsRegex().Replace(text, string.Empty); return plainText.Length <= 200 ? plainText : plainText.Substring(0, 200) + "..."; @@ -84,18 +94,30 @@ private static string GetPostPreview(string text) private static string CreateNewPostEmailContent(string authorName, string postPreview, string groupUrl) { + // HTML-encode to prevent XSS attacks + var encodedAuthorName = WebUtility.HtmlEncode(authorName); + var encodedPostPreview = WebUtility.HtmlEncode(postPreview); + + // Convert newlines to
tags for proper display after encoding + encodedPostPreview = encodedPostPreview.Replace("\r\n", "
").Replace("\n", "
"); + + var encodedGroupUrl = WebUtility.HtmlEncode(groupUrl); + return $"""

Nyt opslag i din gruppe

-

{authorName} har oprettet et nyt opslag:

+

{encodedAuthorName} har oprettet et nyt opslag:

- {postPreview} + {encodedPostPreview}
-

Klik her for at se opslaget

+

Klik her for at se opslaget

{EmailConstants.Signature} """; } + + [GeneratedRegex("<.*?>")] + private static partial Regex HtmlTagsRegex(); } diff --git a/src/web/Jordnaer/Features/GroupPosts/GroupPostService.cs b/src/web/Jordnaer/Features/GroupPosts/GroupPostService.cs index 93485838..4bf60d9a 100644 --- a/src/web/Jordnaer/Features/GroupPosts/GroupPostService.cs +++ b/src/web/Jordnaer/Features/GroupPosts/GroupPostService.cs @@ -11,7 +11,8 @@ namespace Jordnaer.Features.GroupPosts; public class GroupPostService( IDbContextFactory contextFactory, - IPublishEndpoint publishEndpoint) + IPublishEndpoint publishEndpoint, + ILogger logger) { public async Task> GetPostAsync(Guid postId, CancellationToken cancellationToken = default) @@ -87,6 +88,15 @@ await publishEndpoint.Publish(new GroupPostCreated CreatedUtc = post.CreatedUtc }, cancellationToken); } + else + { + // Log warning - this indicates a data integrity issue + logger.LogWarning( + "Skipping GroupPostCreated event publication due to missing data. " + + "PostId: {PostId}, GroupId: {GroupId}, UserProfileId: {UserProfileId}, " + + "GroupFound: {GroupFound}, AuthorFound: {AuthorFound}", + post.Id, post.GroupId, post.UserProfileId, group is not null, author is not null); + } return new Success(); } diff --git a/src/web/Jordnaer/Features/Groups/GroupMembershipHub.cs b/src/web/Jordnaer/Features/Groups/GroupMembershipHub.cs index 66b27d50..7ae1986b 100644 --- a/src/web/Jordnaer/Features/Groups/GroupMembershipHub.cs +++ b/src/web/Jordnaer/Features/Groups/GroupMembershipHub.cs @@ -1,6 +1,8 @@ +using Jordnaer.Database; using Jordnaer.Shared; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.SignalR; +using Microsoft.EntityFrameworkCore; namespace Jordnaer.Features.Groups; @@ -10,7 +12,9 @@ public interface IGroupMembershipHub } [Authorize] -public class GroupMembershipHub(ILogger logger) : Hub +public class GroupMembershipHub( + ILogger logger, + IDbContextFactory contextFactory) : Hub { public override async Task OnConnectedAsync() { @@ -37,11 +41,39 @@ public override async Task OnDisconnectedAsync(Exception? exception) public async Task JoinAdminGroups(List groupIds) { - logger.LogDebug("User {userId} joining {count} admin groups", Context.User?.GetId(), groupIds.Count); + var userId = Context.User?.GetId(); + if (userId is null) + { + logger.LogWarning("Unauthorized attempt to join admin groups - no user ID"); + return; + } + + logger.LogDebug("User {userId} attempting to join {count} admin groups", userId, groupIds.Count); + + await using var context = await contextFactory.CreateDbContextAsync(); + + // Validate that the user is actually an admin or owner of the requested groups + var validGroupIds = await context.GroupMemberships + .AsNoTracking() + .Where(x => x.UserProfileId == userId && + groupIds.Contains(x.GroupId) && + (x.PermissionLevel == PermissionLevel.Admin || + x.OwnershipLevel == OwnershipLevel.Owner)) + .Select(x => x.GroupId) + .ToListAsync(); - foreach (var groupId in groupIds) + if (validGroupIds.Count != groupIds.Count) + { + var invalidGroupIds = groupIds.Except(validGroupIds).ToList(); + logger.LogWarning("User {userId} attempted to join admin groups without authorization. Invalid groups: {invalidGroupIds}", + userId, invalidGroupIds); + } + + foreach (var groupId in validGroupIds) { await Groups.AddToGroupAsync(Context.ConnectionId, $"group-admins-{groupId}"); } + + logger.LogDebug("User {userId} joined {count} admin groups", userId, validGroupIds.Count); } } diff --git a/src/web/Jordnaer/Features/Groups/GroupService.cs b/src/web/Jordnaer/Features/Groups/GroupService.cs index 913449d4..d9471342 100644 --- a/src/web/Jordnaer/Features/Groups/GroupService.cs +++ b/src/web/Jordnaer/Features/Groups/GroupService.cs @@ -288,9 +288,18 @@ public async Task>> UpdateMembership(GroupMembershi try { await context.SaveChangesAsync(cancellationToken); + } + catch (Exception exception) + { + return logger.LogAndReturnErrorResult(exception, + "Det lykkedes ikke at opdatere medlemskabet. Prøv igen senere."); + } - // Notify admins via SignalR if pending count changed - if (wasPending != isPending) + // Notify admins via SignalR if pending count changed + // This is outside the DB transaction to prevent notification failures from affecting DB success + if (wasPending != isPending) + { + try { var pendingCountChange = isPending ? 1 : -1; await hubContext.Clients @@ -301,11 +310,13 @@ await hubContext.Clients PendingCountChange = pendingCountChange }); } - } - catch (Exception exception) - { - return logger.LogAndReturnErrorResult(exception, - "Det lykkedes ikke at opdatere medlemskabet. Prøv igen senere."); + catch (Exception notificationException) + { + // Log but don't fail the request - DB update succeeded + logger.LogError(notificationException, + "Failed to send SignalR notification for membership status change. GroupId: {GroupId}", + membershipDto.GroupId); + } } return new Success(); diff --git a/src/web/Jordnaer/Features/Metrics/JordnaerMetrics.cs b/src/web/Jordnaer/Features/Metrics/JordnaerMetrics.cs index 5d110359..c45f7b07 100644 --- a/src/web/Jordnaer/Features/Metrics/JordnaerMetrics.cs +++ b/src/web/Jordnaer/Features/Metrics/JordnaerMetrics.cs @@ -47,4 +47,11 @@ internal static class JordnaerMetrics internal static readonly Counter AdViewCounter = Meter.CreateCounter("jordnaer_ad_views_total"); + + internal static readonly Counter GroupPostCreatedConsumerReceivedCounter = + Meter.CreateCounter("jordnaer_group_post_created_consumer_received_total"); + internal static readonly Counter GroupPostCreatedConsumerSucceededCounter = + Meter.CreateCounter("jordnaer_group_post_created_consumer_succeeded_total"); + internal static readonly Counter GroupPostCreatedConsumerFailedCounter = + Meter.CreateCounter("jordnaer_group_post_created_consumer_failed_total"); } \ No newline at end of file From 3df3f513f12a6c1918940a13cbf83970bd84a27c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Niels=20Pilgaard=20Gr=C3=B8ndahl?= Date: Thu, 1 Jan 2026 20:17:57 +0100 Subject: [PATCH 04/13] Update GroupServiceTests.cs --- tests/web/Jordnaer.Tests/Groups/GroupServiceTests.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/web/Jordnaer.Tests/Groups/GroupServiceTests.cs b/tests/web/Jordnaer.Tests/Groups/GroupServiceTests.cs index 1d30bfa0..3cf840d0 100644 --- a/tests/web/Jordnaer.Tests/Groups/GroupServiceTests.cs +++ b/tests/web/Jordnaer.Tests/Groups/GroupServiceTests.cs @@ -7,6 +7,7 @@ using Jordnaer.Shared; using Jordnaer.Tests.Infrastructure; using MassTransit; +using Microsoft.AspNetCore.SignalR; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using NSubstitute; @@ -59,7 +60,8 @@ public GroupServiceTests(SqlServerContainer sqlServerContaine new ClaimsIdentity( [new Claim(ClaimTypes.NameIdentifier, _userProfileId)] )) - }); + }, + Substitute.For>()); } [Fact] From 399f50257bdaef14dae753874960c325caf199e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Niels=20Pilgaard=20Gr=C3=B8ndahl?= Date: Thu, 1 Jan 2026 22:17:37 +0100 Subject: [PATCH 05/13] fix facebook+cloud icon colors --- src/web/Jordnaer/Features/Posts/PostCardComponent.razor | 6 ++++-- src/web/Jordnaer/Features/Sharing/SocialShareButtons.razor | 4 ++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/web/Jordnaer/Features/Posts/PostCardComponent.razor b/src/web/Jordnaer/Features/Posts/PostCardComponent.razor index 64f27ba7..1acf883a 100644 --- a/src/web/Jordnaer/Features/Posts/PostCardComponent.razor +++ b/src/web/Jordnaer/Features/Posts/PostCardComponent.razor @@ -43,10 +43,12 @@ - + + Del på Facebook - + + Del på Bluesky diff --git a/src/web/Jordnaer/Features/Sharing/SocialShareButtons.razor b/src/web/Jordnaer/Features/Sharing/SocialShareButtons.razor index baf362bf..fc845463 100644 --- a/src/web/Jordnaer/Features/Sharing/SocialShareButtons.razor +++ b/src/web/Jordnaer/Features/Sharing/SocialShareButtons.razor @@ -4,12 +4,12 @@
- - From 857952907ed621e938f3396c4d524d503af94c26 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Niels=20Pilgaard=20Gr=C3=B8ndahl?= Date: Thu, 1 Jan 2026 22:39:52 +0100 Subject: [PATCH 06/13] fix cookie banner colors --- .../Jordnaer/Components/CookieBanner.razor | 26 ++++++++++--------- 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/src/web/Jordnaer/Components/CookieBanner.razor b/src/web/Jordnaer/Components/CookieBanner.razor index 4f1560b8..92d073e5 100644 --- a/src/web/Jordnaer/Components/CookieBanner.razor +++ b/src/web/Jordnaer/Components/CookieBanner.razor @@ -9,18 +9,20 @@ bool _hideBanner = false; async Task Consent() => await LocalStorage.SetItemAsync(CookieBannerId, true); - RenderFragment _banner = - (@
- - Mini Møder anvender cookies for at forbedre din oplevelse og for at hjælpe os med at forstå, - hvordan vores hjemmeside bliver brugt. - - - Du kan læse mere i vores - Privatlivspolitik og - Servicevilkår. - -
); + RenderFragment _banner => __builder => + { +
+ + Mini Møder anvender cookies for at forbedre din oplevelse og for at hjælpe os med at forstå, + hvordan vores hjemmeside bliver brugt. + + + Du kan læse mere i vores + Privatlivspolitik og + Servicevilkår. + +
+ }; protected override async Task OnAfterRenderAsync(bool firstRender) { From db07186840d57312ae986697cbd55295fe00a5ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Niels=20Pilgaard=20Gr=C3=B8ndahl?= Date: Thu, 1 Jan 2026 22:53:57 +0100 Subject: [PATCH 07/13] allow leaving groups --- .../Database/Enums/MembershipStatus.cs | 5 +- .../Jordnaer.Shared/Groups/GroupMemberSlim.cs | 18 +++++++ .../Groups/GroupMemberListComponent.razor | 14 +++++- .../Jordnaer/Features/Groups/GroupService.cs | 36 +++++++++++++- .../Features/Membership/MembershipService.cs | 47 +++++++++++++++++++ .../Jordnaer/Pages/Groups/GroupDetails.razor | 29 +++++++++++- 6 files changed, 142 insertions(+), 7 deletions(-) create mode 100644 src/shared/Jordnaer.Shared/Groups/GroupMemberSlim.cs diff --git a/src/shared/Jordnaer.Shared/Database/Enums/MembershipStatus.cs b/src/shared/Jordnaer.Shared/Database/Enums/MembershipStatus.cs index 96b740bf..1d68c898 100644 --- a/src/shared/Jordnaer.Shared/Database/Enums/MembershipStatus.cs +++ b/src/shared/Jordnaer.Shared/Database/Enums/MembershipStatus.cs @@ -16,5 +16,8 @@ public enum MembershipStatus PendingApprovalFromUser = 2, [Display(Name = "Afvist")] - Rejected = 3 + Rejected = 3, + + [Display(Name = "Forladt")] + Left = 4 } diff --git a/src/shared/Jordnaer.Shared/Groups/GroupMemberSlim.cs b/src/shared/Jordnaer.Shared/Groups/GroupMemberSlim.cs new file mode 100644 index 00000000..918efa7c --- /dev/null +++ b/src/shared/Jordnaer.Shared/Groups/GroupMemberSlim.cs @@ -0,0 +1,18 @@ +namespace Jordnaer.Shared; + +public class GroupMemberSlim +{ + public required string Id { get; init; } + + public required string DisplayName { get; init; } + + public required string ProfilePictureUrl { get; init; } + + public required string? UserName { get; init; } + + public required OwnershipLevel OwnershipLevel { get; init; } + + public required PermissionLevel PermissionLevel { get; init; } + + public override string ToString() => DisplayName; +} diff --git a/src/web/Jordnaer/Features/Groups/GroupMemberListComponent.razor b/src/web/Jordnaer/Features/Groups/GroupMemberListComponent.razor index 109b3e9d..597693e7 100644 --- a/src/web/Jordnaer/Features/Groups/GroupMemberListComponent.razor +++ b/src/web/Jordnaer/Features/Groups/GroupMemberListComponent.razor @@ -27,7 +27,17 @@ else - @member.DisplayName.Sanitize() +
+ @member.DisplayName.Sanitize() + @if (member.OwnershipLevel == OwnershipLevel.Owner) + { + Ejer + } + else if (member.PermissionLevel == PermissionLevel.Admin) + { + Admin + } +
} @@ -40,5 +50,5 @@ else public bool CurrentUserIsAdmin { get; set; } = false; [Parameter, EditorRequired] - public required List? GroupMembers { get; set; } + public required List? GroupMembers { get; set; } } \ No newline at end of file diff --git a/src/web/Jordnaer/Features/Groups/GroupService.cs b/src/web/Jordnaer/Features/Groups/GroupService.cs index d9471342..38cf55ab 100644 --- a/src/web/Jordnaer/Features/Groups/GroupService.cs +++ b/src/web/Jordnaer/Features/Groups/GroupService.cs @@ -70,6 +70,11 @@ public async Task> GetSlimGroupsForUserAsync(CancellationT { logger.LogFunctionBegan(); + if (currentUser.Id is null) + { + return []; + } + await using var context = await contextFactory.CreateDbContextAsync(cancellationToken); var groups = await context.GroupMemberships .AsNoTracking() @@ -131,6 +136,30 @@ public async Task> GetGroupMembersByPredicateAsync(Expression> GetGroupMembersWithRolesByPredicateAsync(Expression> predicate, CancellationToken cancellationToken = default) + { + logger.LogFunctionBegan(); + + await using var context = await contextFactory.CreateDbContextAsync(cancellationToken); + var members = await context.GroupMemberships + .AsNoTracking() + .Where(predicate) + .OrderByDescending(x => x.OwnershipLevel) + .ThenByDescending(x => x.PermissionLevel) + .Select(x => new GroupMemberSlim + { + DisplayName = x.UserProfile.DisplayName, + Id = x.UserProfileId, + ProfilePictureUrl = x.UserProfile.ProfilePictureUrl, + UserName = x.UserProfile.UserName, + OwnershipLevel = x.OwnershipLevel, + PermissionLevel = x.PermissionLevel + }) + .ToListAsync(cancellationToken); + + return members; + } + public async Task> GetGroupMembershipsAsync(string groupName, CancellationToken cancellationToken = default) { await using var context = await contextFactory.CreateDbContextAsync(cancellationToken); @@ -203,10 +232,13 @@ public async Task GetPendingMembershipCountAsync(Guid groupId, Cancellation /// public async Task> GetPendingMembershipCountsForUserAsync(CancellationToken cancellationToken = default) { - Debug.Assert(currentUser.Id is not null, "Current user must be set when fetching pending counts."); - logger.LogFunctionBegan(); + if (currentUser.Id is null) + { + return new Dictionary(); + } + await using var context = await contextFactory.CreateDbContextAsync(cancellationToken); // Get all groups where current user is admin or owner diff --git a/src/web/Jordnaer/Features/Membership/MembershipService.cs b/src/web/Jordnaer/Features/Membership/MembershipService.cs index 65c9eabe..b5ecb3b3 100644 --- a/src/web/Jordnaer/Features/Membership/MembershipService.cs +++ b/src/web/Jordnaer/Features/Membership/MembershipService.cs @@ -14,6 +14,10 @@ public interface IMembershipService Task, Error>> RequestMembership( string groupName, CancellationToken cancellationToken = default); + + Task>> LeaveMembership( + string groupName, + CancellationToken cancellationToken = default); } public class MembershipService(CurrentUser currentUser, @@ -70,4 +74,47 @@ public async Task, Error>> Reques return new Error("Der skete en fejl. Prøv igen senere."); } } + + public async Task>> LeaveMembership( + string groupName, + CancellationToken cancellationToken = default) + { + logger.LogFunctionBegan(); + + try + { + await using var context = await contextFactory.CreateDbContextAsync(cancellationToken); + + var membership = await context.GroupMemberships + .Include(x => x.Group) + .FirstOrDefaultAsync(x => x.Group.Name == groupName && x.UserProfileId == currentUser.Id, cancellationToken); + + if (membership is null) + { + return new Error("Du er ikke medlem af denne gruppe."); + } + + if (membership.MembershipStatus != MembershipStatus.Active) + { + return new Error("Du kan kun forlade grupper, hvor du er et aktivt medlem."); + } + + if (membership.OwnershipLevel == OwnershipLevel.Owner) + { + return new Error("Ejeren kan ikke forlade gruppen. Overdrag først ejerskabet til et andet medlem."); + } + + // Soft delete: change status to Left instead of removing the record + membership.MembershipStatus = MembershipStatus.Left; + membership.LastUpdatedUtc = DateTime.UtcNow; + await context.SaveChangesAsync(cancellationToken); + + return new Success(); + } + catch (Exception exception) + { + logger.LogException(exception); + return new Error("Der skete en fejl. Prøv igen senere."); + } + } } \ No newline at end of file diff --git a/src/web/Jordnaer/Pages/Groups/GroupDetails.razor b/src/web/Jordnaer/Pages/Groups/GroupDetails.razor index bc90a497..21acd4f7 100644 --- a/src/web/Jordnaer/Pages/Groups/GroupDetails.razor +++ b/src/web/Jordnaer/Pages/Groups/GroupDetails.razor @@ -51,6 +51,14 @@ } + @if (_isMember && !_currentUserIsOwner) + { + + Forlad Gruppe + + } + @if (_currentUserIsAdmin && _group is not null) { _admins.Select(x => x.Id).Contains(_currentUser?.Id); + private bool _currentUserIsOwner = false; private List _recipients = []; - private List? _groupMembers; + private List? _groupMembers; private List _admins = []; private GroupPostList? _groupPostList; private LeafletMap? _leafletMap; @@ -257,11 +266,13 @@ _currentUser = await ProfileCache.GetProfileAsync(); if (_currentUser is null) { + _groupMembers = await GroupService.GetGroupMembersWithRolesByPredicateAsync(x => x.GroupId == _group.Id && + x.MembershipStatus == MembershipStatus.Active); _isLoading = false; return; } - _groupMembers = await GroupService.GetGroupMembersByPredicateAsync(x => x.GroupId == _group.Id && + _groupMembers = await GroupService.GetGroupMembersWithRolesByPredicateAsync(x => x.GroupId == _group.Id && x.MembershipStatus == MembershipStatus.Active); var currentUsersGroupMembership = await GroupService.GetCurrentUsersGroupMembershipAsync(_group.Id); @@ -270,6 +281,7 @@ _hasPendingMembershipRequest = currentUsersGroupMembership?.MembershipStatus is MembershipStatus.PendingApprovalFromGroup or MembershipStatus.PendingApprovalFromUser; + _currentUserIsOwner = currentUsersGroupMembership?.OwnershipLevel is OwnershipLevel.Owner; _admins = await GroupService .GetGroupMembersByPredicateAsync(x => @@ -302,6 +314,19 @@ error => Snackbar.Add(error.Value, Severity.Warning)); } + private async Task LeaveGroup() + { + var leaveResponse = await MembershipService.LeaveMembership(GroupName, CancellationToken); + leaveResponse.Switch( + _ => + { + Snackbar.Add("Du har forladt gruppen", Severity.Success); + _isMember = false; + StateHasChanged(); + }, + error => Snackbar.Add(error.Value, Severity.Warning)); + } + private static string FormatLocation(string? city, int? zipCode) { if (!string.IsNullOrEmpty(city) && zipCode.HasValue) From ace35f0e47b4a9aec773dea65a7cfb407296afb3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Niels=20Pilgaard=20Gr=C3=B8ndahl?= Date: Thu, 1 Jan 2026 23:06:10 +0100 Subject: [PATCH 08/13] revamp signalr approach to be sturdier --- .../Features/Dashboard/GroupsHub.razor | 6 -- .../Features/Groups/GroupMembershipHub.cs | 44 +---------- .../GroupMembershipNotificationService.cs | 74 +++++++++++++++++++ .../Groups/GroupMembershipSignalRClient.cs | 10 --- .../Jordnaer/Features/Groups/GroupService.cs | 24 +----- .../Groups/WebApplicationBuilderExtensions.cs | 1 + .../Features/Membership/MembershipService.cs | 26 ++++++- src/web/Jordnaer/Pages/Shared/TopBar.razor | 9 +-- 8 files changed, 104 insertions(+), 90 deletions(-) create mode 100644 src/web/Jordnaer/Features/Groups/GroupMembershipNotificationService.cs diff --git a/src/web/Jordnaer/Features/Dashboard/GroupsHub.razor b/src/web/Jordnaer/Features/Dashboard/GroupsHub.razor index 9bc4e4c7..7349fe1e 100644 --- a/src/web/Jordnaer/Features/Dashboard/GroupsHub.razor +++ b/src/web/Jordnaer/Features/Dashboard/GroupsHub.razor @@ -189,12 +189,6 @@ await GroupMembershipSignalRClient.StartAsync(); - // Join SignalR groups for admin notifications - if (adminGroupIds.Count > 0) - { - await GroupMembershipSignalRClient.JoinAdminGroupsAsync(adminGroupIds); - } - _isLoading = false; } diff --git a/src/web/Jordnaer/Features/Groups/GroupMembershipHub.cs b/src/web/Jordnaer/Features/Groups/GroupMembershipHub.cs index 7ae1986b..edf3df0a 100644 --- a/src/web/Jordnaer/Features/Groups/GroupMembershipHub.cs +++ b/src/web/Jordnaer/Features/Groups/GroupMembershipHub.cs @@ -1,8 +1,6 @@ -using Jordnaer.Database; using Jordnaer.Shared; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.SignalR; -using Microsoft.EntityFrameworkCore; namespace Jordnaer.Features.Groups; @@ -12,9 +10,7 @@ public interface IGroupMembershipHub } [Authorize] -public class GroupMembershipHub( - ILogger logger, - IDbContextFactory contextFactory) : Hub +public class GroupMembershipHub(ILogger logger) : Hub { public override async Task OnConnectedAsync() { @@ -38,42 +34,4 @@ public override async Task OnDisconnectedAsync(Exception? exception) await base.OnDisconnectedAsync(exception); } - - public async Task JoinAdminGroups(List groupIds) - { - var userId = Context.User?.GetId(); - if (userId is null) - { - logger.LogWarning("Unauthorized attempt to join admin groups - no user ID"); - return; - } - - logger.LogDebug("User {userId} attempting to join {count} admin groups", userId, groupIds.Count); - - await using var context = await contextFactory.CreateDbContextAsync(); - - // Validate that the user is actually an admin or owner of the requested groups - var validGroupIds = await context.GroupMemberships - .AsNoTracking() - .Where(x => x.UserProfileId == userId && - groupIds.Contains(x.GroupId) && - (x.PermissionLevel == PermissionLevel.Admin || - x.OwnershipLevel == OwnershipLevel.Owner)) - .Select(x => x.GroupId) - .ToListAsync(); - - if (validGroupIds.Count != groupIds.Count) - { - var invalidGroupIds = groupIds.Except(validGroupIds).ToList(); - logger.LogWarning("User {userId} attempted to join admin groups without authorization. Invalid groups: {invalidGroupIds}", - userId, invalidGroupIds); - } - - foreach (var groupId in validGroupIds) - { - await Groups.AddToGroupAsync(Context.ConnectionId, $"group-admins-{groupId}"); - } - - logger.LogDebug("User {userId} joined {count} admin groups", userId, validGroupIds.Count); - } } diff --git a/src/web/Jordnaer/Features/Groups/GroupMembershipNotificationService.cs b/src/web/Jordnaer/Features/Groups/GroupMembershipNotificationService.cs new file mode 100644 index 00000000..b3923845 --- /dev/null +++ b/src/web/Jordnaer/Features/Groups/GroupMembershipNotificationService.cs @@ -0,0 +1,74 @@ +using Jordnaer.Database; +using Jordnaer.Shared; +using Microsoft.AspNetCore.SignalR; +using Microsoft.EntityFrameworkCore; + +namespace Jordnaer.Features.Groups; + +public interface IGroupMembershipNotificationService +{ + /// + /// Notifies group admins and owners of a change in pending membership requests. + /// + /// The ID of the group + /// The change in pending count (e.g., +1 for new request, -1 for approval/rejection) + /// Cancellation token + Task NotifyAdminsOfPendingCountChangeAsync( + Guid groupId, + int pendingCountChange, + CancellationToken cancellationToken = default); +} + +public class GroupMembershipNotificationService( + IDbContextFactory contextFactory, + IHubContext hubContext, + ILogger logger) : IGroupMembershipNotificationService +{ + public async Task NotifyAdminsOfPendingCountChangeAsync( + Guid groupId, + int pendingCountChange, + CancellationToken cancellationToken = default) + { + try + { + await using var context = await contextFactory.CreateDbContextAsync(cancellationToken); + var adminUserIds = await GetGroupAdminUserIdsAsync(groupId, context, cancellationToken); + + if (adminUserIds.Count > 0) + { + await hubContext.Clients + .Users(adminUserIds) + .MembershipStatusChanged(new GroupMembershipStatusChanged + { + GroupId = groupId, + PendingCountChange = pendingCountChange + }); + } + } + catch (Exception exception) + { + // Log but don't throw - notification failures shouldn't affect the operation + logger.LogError(exception, + "Failed to send SignalR notification for membership status change. GroupId: {GroupId}, PendingCountChange: {PendingCountChange}", + groupId, pendingCountChange); + } + } + + /// + /// Gets the user IDs of all admins and owners for a specific group. + /// + private static async Task> GetGroupAdminUserIdsAsync( + Guid groupId, + JordnaerDbContext context, + CancellationToken cancellationToken = default) + { + return await context.GroupMemberships + .AsNoTracking() + .Where(x => x.GroupId == groupId && + x.MembershipStatus == MembershipStatus.Active && + (x.PermissionLevel == PermissionLevel.Admin || + x.OwnershipLevel == OwnershipLevel.Owner)) + .Select(x => x.UserProfileId) + .ToListAsync(cancellationToken); + } +} diff --git a/src/web/Jordnaer/Features/Groups/GroupMembershipSignalRClient.cs b/src/web/Jordnaer/Features/Groups/GroupMembershipSignalRClient.cs index 82b9559a..6c697ab0 100644 --- a/src/web/Jordnaer/Features/Groups/GroupMembershipSignalRClient.cs +++ b/src/web/Jordnaer/Features/Groups/GroupMembershipSignalRClient.cs @@ -22,14 +22,4 @@ public void OnMembershipStatusChanged(Func a HubConnection.Remove(nameof(IGroupMembershipHub.MembershipStatusChanged)); HubConnection.On(nameof(IGroupMembershipHub.MembershipStatusChanged), action); } - - public async Task JoinAdminGroupsAsync(List groupIds) - { - if (HubConnection is null || !IsConnected) - { - return; - } - - await HubConnection.InvokeAsync(nameof(GroupMembershipHub.JoinAdminGroups), groupIds); - } } diff --git a/src/web/Jordnaer/Features/Groups/GroupService.cs b/src/web/Jordnaer/Features/Groups/GroupService.cs index 38cf55ab..16696763 100644 --- a/src/web/Jordnaer/Features/Groups/GroupService.cs +++ b/src/web/Jordnaer/Features/Groups/GroupService.cs @@ -3,7 +3,6 @@ using Jordnaer.Features.Authentication; using Jordnaer.Features.Metrics; using Jordnaer.Shared; -using Microsoft.AspNetCore.SignalR; using Microsoft.EntityFrameworkCore; using OneOf; using OneOf.Types; @@ -19,7 +18,7 @@ public class GroupService( ILogger logger, IDiagnosticContext diagnosticContext, CurrentUser currentUser, - IHubContext hubContext) + IGroupMembershipNotificationService notificationService) { public async Task> GetGroupByIdAsync(Guid id, CancellationToken cancellationToken = default) { @@ -267,6 +266,7 @@ public async Task> GetPendingMembershipCountsForUserAsync( return pendingCounts; } + public async Task>> UpdateMembership(GroupMembershipDto membershipDto, CancellationToken cancellationToken = default) { Debug.Assert(currentUser.Id is not null, "Current user must be set when updating group membership."); @@ -331,24 +331,8 @@ public async Task>> UpdateMembership(GroupMembershi // This is outside the DB transaction to prevent notification failures from affecting DB success if (wasPending != isPending) { - try - { - var pendingCountChange = isPending ? 1 : -1; - await hubContext.Clients - .Group($"group-admins-{membershipDto.GroupId}") - .MembershipStatusChanged(new GroupMembershipStatusChanged - { - GroupId = membershipDto.GroupId, - PendingCountChange = pendingCountChange - }); - } - catch (Exception notificationException) - { - // Log but don't fail the request - DB update succeeded - logger.LogError(notificationException, - "Failed to send SignalR notification for membership status change. GroupId: {GroupId}", - membershipDto.GroupId); - } + var pendingCountChange = isPending ? 1 : -1; + await notificationService.NotifyAdminsOfPendingCountChangeAsync(membershipDto.GroupId, pendingCountChange, cancellationToken); } return new Success(); diff --git a/src/web/Jordnaer/Features/Groups/WebApplicationBuilderExtensions.cs b/src/web/Jordnaer/Features/Groups/WebApplicationBuilderExtensions.cs index f1a0bad4..066898df 100644 --- a/src/web/Jordnaer/Features/Groups/WebApplicationBuilderExtensions.cs +++ b/src/web/Jordnaer/Features/Groups/WebApplicationBuilderExtensions.cs @@ -6,6 +6,7 @@ public static WebApplicationBuilder AddGroupServices(this WebApplicationBuilder { builder.Services.AddScoped(); builder.Services.AddScoped(); + builder.Services.AddScoped(); return builder; } diff --git a/src/web/Jordnaer/Features/Membership/MembershipService.cs b/src/web/Jordnaer/Features/Membership/MembershipService.cs index b5ecb3b3..1bee27e5 100644 --- a/src/web/Jordnaer/Features/Membership/MembershipService.cs +++ b/src/web/Jordnaer/Features/Membership/MembershipService.cs @@ -1,7 +1,8 @@ -using Jordnaer.Database; +using Jordnaer.Database; using Jordnaer.Extensions; using Jordnaer.Features.Authentication; using Jordnaer.Features.Email; +using Jordnaer.Features.Groups; using Jordnaer.Shared; using Microsoft.EntityFrameworkCore; using OneOf; @@ -23,7 +24,8 @@ Task>> LeaveMembership( public class MembershipService(CurrentUser currentUser, IDbContextFactory contextFactory, IEmailService emailService, - ILogger logger) : IMembershipService + ILogger logger, + IGroupMembershipNotificationService notificationService) : IMembershipService { public async Task, Error>> RequestMembership( string groupName, @@ -47,6 +49,21 @@ public async Task, Error>> Reques var existingMembership = group.Memberships.FirstOrDefault(x => x.UserProfileId == currentUser.Id); if (existingMembership is not null) { + // Allow re-application if user has left or been rejected + if (existingMembership.MembershipStatus is MembershipStatus.Left or MembershipStatus.Rejected) + { + existingMembership.MembershipStatus = MembershipStatus.PendingApprovalFromGroup; + existingMembership.LastUpdatedUtc = DateTime.UtcNow; + existingMembership.UserInitiatedMembership = true; + await context.SaveChangesAsync(cancellationToken); + await emailService.SendMembershipRequestEmails(groupName, cancellationToken); + + // Notify admins via SignalR about the new pending request + await notificationService.NotifyAdminsOfPendingCountChangeAsync(group.Id, 1, cancellationToken); + + return new Success(); + } + return new Error(existingMembership.MembershipStatus); } @@ -66,6 +83,9 @@ public async Task, Error>> Reques await emailService.SendMembershipRequestEmails(groupName, cancellationToken); + // Notify admins via SignalR about the new pending request + await notificationService.NotifyAdminsOfPendingCountChangeAsync(group.Id, 1, cancellationToken); + return new Success(); } catch (Exception exception) @@ -117,4 +137,4 @@ public async Task>> LeaveMembership( return new Error("Der skete en fejl. Prøv igen senere."); } } -} \ No newline at end of file +} diff --git a/src/web/Jordnaer/Pages/Shared/TopBar.razor b/src/web/Jordnaer/Pages/Shared/TopBar.razor index 09a8d93e..f0b371b2 100644 --- a/src/web/Jordnaer/Pages/Shared/TopBar.razor +++ b/src/web/Jordnaer/Pages/Shared/TopBar.razor @@ -205,10 +205,9 @@ // Get initial unread count _unreadCount = await ChatService.GetUnreadMessageCountAsync(CurrentUser.Id); - // Get pending group membership requests count and admin group IDs + // Get pending group membership requests count var pendingCounts = await GroupService.GetPendingMembershipCountsForUserAsync(); _pendingGroupRequestCount = pendingCounts.Values.Sum(); - var adminGroupIds = pendingCounts.Keys.ToList(); // Subscribe to real-time updates UnreadMessageSignalRClient.OnMessageReceived(async message => @@ -250,12 +249,6 @@ await UnreadMessageSignalRClient.StartAsync(); await GroupMembershipSignalRClient.StartAsync(); - - // Join SignalR groups for admin notifications - if (adminGroupIds.Count > 0) - { - await GroupMembershipSignalRClient.JoinAdminGroupsAsync(adminGroupIds); - } } protected override async Task OnAfterRenderAsync(bool firstRender) From 50f27c1d43422e991e3ba80abe579efd1b5980d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Niels=20Pilgaard=20Gr=C3=B8ndahl?= Date: Fri, 2 Jan 2026 00:00:10 +0100 Subject: [PATCH 09/13] finish group membership signal r impl --- .../Features/Dashboard/GroupsHub.razor | 18 +++--------------- .../GroupMembershipNotificationService.cs | 10 ++++++++++ .../Jordnaer/Features/Groups/GroupService.cs | 2 +- 3 files changed, 14 insertions(+), 16 deletions(-) diff --git a/src/web/Jordnaer/Features/Dashboard/GroupsHub.razor b/src/web/Jordnaer/Features/Dashboard/GroupsHub.razor index 7349fe1e..0cdaee15 100644 --- a/src/web/Jordnaer/Features/Dashboard/GroupsHub.razor +++ b/src/web/Jordnaer/Features/Dashboard/GroupsHub.razor @@ -1,7 +1,6 @@ @page "/groups" @inject GroupService GroupService -@inject GroupMembershipSignalRClient GroupMembershipSignalRClient @inject NavigationManager Navigation @implements IAsyncDisposable @@ -176,19 +175,6 @@ _totalPendingRequests = pendingCounts.Values.Sum(); var adminGroupIds = pendingCounts.Keys.ToList(); - // Subscribe to group membership status changes - GroupMembershipSignalRClient.OnMembershipStatusChanged(async notification => - { - _totalPendingRequests += notification.PendingCountChange; - if (_totalPendingRequests < 0) - { - _totalPendingRequests = 0; - } - await InvokeAsync(StateHasChanged); - }); - - await GroupMembershipSignalRClient.StartAsync(); - _isLoading = false; } @@ -206,6 +192,8 @@ public async ValueTask DisposeAsync() { - await GroupMembershipSignalRClient.StopAsync(); + // Don't stop SignalR client here - it should persist for the entire user session + // The client will be disposed when the DI scope ends (when user closes browser/logs out) + await ValueTask.CompletedTask; } } \ No newline at end of file diff --git a/src/web/Jordnaer/Features/Groups/GroupMembershipNotificationService.cs b/src/web/Jordnaer/Features/Groups/GroupMembershipNotificationService.cs index b3923845..13996e30 100644 --- a/src/web/Jordnaer/Features/Groups/GroupMembershipNotificationService.cs +++ b/src/web/Jordnaer/Features/Groups/GroupMembershipNotificationService.cs @@ -34,6 +34,10 @@ public async Task NotifyAdminsOfPendingCountChangeAsync( await using var context = await contextFactory.CreateDbContextAsync(cancellationToken); var adminUserIds = await GetGroupAdminUserIdsAsync(groupId, context, cancellationToken); + logger.LogInformation( + "Sending membership status notification: GroupId={GroupId}, PendingCountChange={PendingCountChange}, AdminCount={AdminCount}, AdminUserIds={AdminUserIds}", + groupId, pendingCountChange, adminUserIds.Count, string.Join(", ", adminUserIds)); + if (adminUserIds.Count > 0) { await hubContext.Clients @@ -43,6 +47,12 @@ await hubContext.Clients GroupId = groupId, PendingCountChange = pendingCountChange }); + + logger.LogInformation("Successfully sent membership status notification to {AdminCount} admins", adminUserIds.Count); + } + else + { + logger.LogWarning("No admins found for group {GroupId}, skipping notification", groupId); } } catch (Exception exception) diff --git a/src/web/Jordnaer/Features/Groups/GroupService.cs b/src/web/Jordnaer/Features/Groups/GroupService.cs index 16696763..321ae8c5 100644 --- a/src/web/Jordnaer/Features/Groups/GroupService.cs +++ b/src/web/Jordnaer/Features/Groups/GroupService.cs @@ -266,7 +266,6 @@ public async Task> GetPendingMembershipCountsForUserAsync( return pendingCounts; } - public async Task>> UpdateMembership(GroupMembershipDto membershipDto, CancellationToken cancellationToken = default) { Debug.Assert(currentUser.Id is not null, "Current user must be set when updating group membership."); @@ -305,6 +304,7 @@ public async Task>> UpdateMembership(GroupMembershi } // Track if pending status changed to notify listeners + // Only track PendingApprovalFromGroup (incoming requests), not PendingApprovalFromUser (outgoing invitations) var oldStatus = membership.MembershipStatus; var newStatus = membershipDto.MembershipStatus; var wasPending = oldStatus == MembershipStatus.PendingApprovalFromGroup; From b34738ab49ca50bb70f55e452e8152b6eb2febc8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Niels=20Pilgaard=20Gr=C3=B8ndahl?= Date: Fri, 2 Jan 2026 10:12:05 +0100 Subject: [PATCH 10/13] resolve signalr / db issue --- .../Features/Membership/MembershipService.cs | 43 ++++++++++++++++--- 1 file changed, 37 insertions(+), 6 deletions(-) diff --git a/src/web/Jordnaer/Features/Membership/MembershipService.cs b/src/web/Jordnaer/Features/Membership/MembershipService.cs index 1bee27e5..5344af04 100644 --- a/src/web/Jordnaer/Features/Membership/MembershipService.cs +++ b/src/web/Jordnaer/Features/Membership/MembershipService.cs @@ -56,10 +56,26 @@ public async Task, Error>> Reques existingMembership.LastUpdatedUtc = DateTime.UtcNow; existingMembership.UserInitiatedMembership = true; await context.SaveChangesAsync(cancellationToken); - await emailService.SendMembershipRequestEmails(groupName, cancellationToken); - // Notify admins via SignalR about the new pending request - await notificationService.NotifyAdminsOfPendingCountChangeAsync(group.Id, 1, cancellationToken); + // Send email notification - don't fail the request if notification fails + try + { + await emailService.SendMembershipRequestEmails(groupName, cancellationToken); + } + catch (Exception notificationException) + { + logger.LogError(notificationException, "Failed to send membership request emails for group {GroupName}", groupName); + } + + // Notify admins via SignalR - don't fail the request if notification fails + try + { + await notificationService.NotifyAdminsOfPendingCountChangeAsync(group.Id, 1, cancellationToken); + } + catch (Exception notificationException) + { + logger.LogError(notificationException, "Failed to send SignalR notification for group {GroupId}", group.Id); + } return new Success(); } @@ -81,10 +97,25 @@ public async Task, Error>> Reques await context.SaveChangesAsync(cancellationToken); - await emailService.SendMembershipRequestEmails(groupName, cancellationToken); + // Send email notification - don't fail the request if notification fails + try + { + await emailService.SendMembershipRequestEmails(groupName, cancellationToken); + } + catch (Exception notificationException) + { + logger.LogError(notificationException, "Failed to send membership request emails for group {GroupName}", groupName); + } - // Notify admins via SignalR about the new pending request - await notificationService.NotifyAdminsOfPendingCountChangeAsync(group.Id, 1, cancellationToken); + // Notify admins via SignalR - don't fail the request if notification fails + try + { + await notificationService.NotifyAdminsOfPendingCountChangeAsync(group.Id, 1, cancellationToken); + } + catch (Exception notificationException) + { + logger.LogError(notificationException, "Failed to send SignalR notification for group {GroupId}", group.Id); + } return new Success(); } From 4b80e2555ec9f0bef74a32b3f2638cdcfe439768 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Niels=20Pilgaard=20Gr=C3=B8ndahl?= Date: Fri, 2 Jan 2026 10:12:09 +0100 Subject: [PATCH 11/13] Update GroupService.cs --- src/web/Jordnaer/Features/Groups/GroupService.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/web/Jordnaer/Features/Groups/GroupService.cs b/src/web/Jordnaer/Features/Groups/GroupService.cs index 321ae8c5..07601919 100644 --- a/src/web/Jordnaer/Features/Groups/GroupService.cs +++ b/src/web/Jordnaer/Features/Groups/GroupService.cs @@ -78,7 +78,8 @@ public async Task> GetSlimGroupsForUserAsync(CancellationT var groups = await context.GroupMemberships .AsNoTracking() .Where(membership => membership.UserProfileId == currentUser.Id && - membership.MembershipStatus != MembershipStatus.Rejected) + membership.MembershipStatus != MembershipStatus.Rejected && + membership.MembershipStatus != MembershipStatus.Left) .Select(x => new UserGroupAccess { Group = new GroupSlim From 3924be29d571918b57cae5fa5fda16995f396c05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Niels=20Pilgaard=20Gr=C3=B8ndahl?= Date: Fri, 2 Jan 2026 10:14:15 +0100 Subject: [PATCH 12/13] Update CookieBanner.razor --- src/web/Jordnaer/Components/CookieBanner.razor | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/web/Jordnaer/Components/CookieBanner.razor b/src/web/Jordnaer/Components/CookieBanner.razor index 92d073e5..6aa48584 100644 --- a/src/web/Jordnaer/Components/CookieBanner.razor +++ b/src/web/Jordnaer/Components/CookieBanner.razor @@ -9,7 +9,7 @@ bool _hideBanner = false; async Task Consent() => await LocalStorage.SetItemAsync(CookieBannerId, true); - RenderFragment _banner => __builder => + readonly RenderFragment _banner = __builder => {
From 63373ec14b1a27cff2f2f030c764ddde0d0a6702 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Niels=20Pilgaard=20Gr=C3=B8ndahl?= Date: Fri, 2 Jan 2026 10:16:46 +0100 Subject: [PATCH 13/13] Update GroupServiceTests.cs --- tests/web/Jordnaer.Tests/Groups/GroupServiceTests.cs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/web/Jordnaer.Tests/Groups/GroupServiceTests.cs b/tests/web/Jordnaer.Tests/Groups/GroupServiceTests.cs index 3cf840d0..b129ffe2 100644 --- a/tests/web/Jordnaer.Tests/Groups/GroupServiceTests.cs +++ b/tests/web/Jordnaer.Tests/Groups/GroupServiceTests.cs @@ -7,7 +7,6 @@ using Jordnaer.Shared; using Jordnaer.Tests.Infrastructure; using MassTransit; -using Microsoft.AspNetCore.SignalR; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using NSubstitute; @@ -61,7 +60,7 @@ public GroupServiceTests(SqlServerContainer sqlServerContaine [new Claim(ClaimTypes.NameIdentifier, _userProfileId)] )) }, - Substitute.For>()); + Substitute.For()); } [Fact]