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/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/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/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/Components/CookieBanner.razor b/src/web/Jordnaer/Components/CookieBanner.razor index 4f1560b8..6aa48584 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. - -
); + readonly 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) { diff --git a/src/web/Jordnaer/Consumers/GroupPostCreatedConsumer.cs b/src/web/Jordnaer/Consumers/GroupPostCreatedConsumer.cs new file mode 100644 index 00000000..e5c36d39 --- /dev/null +++ b/src/web/Jordnaer/Consumers/GroupPostCreatedConsumer.cs @@ -0,0 +1,123 @@ +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 partial class GroupPostCreatedConsumer( + IDbContextFactory contextFactory, + ILogger logger, + IPublishEndpoint publishEndpoint, + NavigationManager navigationManager) : IConsumer +{ + 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); + + 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); + JordnaerMetrics.GroupPostCreatedConsumerSucceededCounter.Add(1); + 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); + + 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 + } + } + + private static string GetPostPreview(string text) + { + // Strip HTML tags and limit to 200 characters + var plainText = HtmlTagsRegex().Replace(text, string.Empty); + return plainText.Length <= 200 + ? plainText + : plainText.Substring(0, 200) + "..."; + } + + 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

+ +

{encodedAuthorName} har oprettet et nyt opslag:

+ +
+ {encodedPostPreview} +
+ +

Klik her for at se opslaget

+ + {EmailConstants.Signature} + """; + } + + [GeneratedRegex("<.*?>")] + private static partial Regex HtmlTagsRegex(); +} diff --git a/src/web/Jordnaer/Features/Dashboard/GroupsHub.razor b/src/web/Jordnaer/Features/Dashboard/GroupsHub.razor index 23f5f0b1..0cdaee15 100644 --- a/src/web/Jordnaer/Features/Dashboard/GroupsHub.razor +++ b/src/web/Jordnaer/Features/Dashboard/GroupsHub.razor @@ -3,6 +3,8 @@ @inject GroupService GroupService @inject NavigationManager Navigation +@implements IAsyncDisposable + @@ -13,6 +15,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 +159,7 @@ @code { private bool _isLoading = true; private List _recentGroups = new(); + private int _totalPendingRequests = 0; protected override async Task OnInitializedAsync() { @@ -147,6 +169,12 @@ } await LoadRecentGroups(); + + // Get pending counts and admin group IDs + var pendingCounts = await GroupService.GetPendingMembershipCountsForUserAsync(); + _totalPendingRequests = pendingCounts.Values.Sum(); + var adminGroupIds = pendingCounts.Keys.ToList(); + _isLoading = false; } @@ -161,4 +189,11 @@ .Take(5) .ToList(); } + + public async ValueTask DisposeAsync() + { + // 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/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..4bf60d9a 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,10 @@ namespace Jordnaer.Features.GroupPosts; -public class GroupPostService(IDbContextFactory contextFactory) +public class GroupPostService( + IDbContextFactory contextFactory, + IPublishEndpoint publishEndpoint, + ILogger logger) { public async Task> GetPostAsync(Guid postId, CancellationToken cancellationToken = default) @@ -58,6 +62,42 @@ 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); + } + 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/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/GroupMembershipHub.cs b/src/web/Jordnaer/Features/Groups/GroupMembershipHub.cs new file mode 100644 index 00000000..edf3df0a --- /dev/null +++ b/src/web/Jordnaer/Features/Groups/GroupMembershipHub.cs @@ -0,0 +1,37 @@ +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); + } +} diff --git a/src/web/Jordnaer/Features/Groups/GroupMembershipNotificationService.cs b/src/web/Jordnaer/Features/Groups/GroupMembershipNotificationService.cs new file mode 100644 index 00000000..13996e30 --- /dev/null +++ b/src/web/Jordnaer/Features/Groups/GroupMembershipNotificationService.cs @@ -0,0 +1,84 @@ +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); + + 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 + .Users(adminUserIds) + .MembershipStatusChanged(new GroupMembershipStatusChanged + { + 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) + { + // 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 new file mode 100644 index 00000000..6c697ab0 --- /dev/null +++ b/src/web/Jordnaer/Features/Groups/GroupMembershipSignalRClient.cs @@ -0,0 +1,25 @@ +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); + } +} diff --git a/src/web/Jordnaer/Features/Groups/GroupService.cs b/src/web/Jordnaer/Features/Groups/GroupService.cs index b6bc3606..07601919 100644 --- a/src/web/Jordnaer/Features/Groups/GroupService.cs +++ b/src/web/Jordnaer/Features/Groups/GroupService.cs @@ -17,7 +17,8 @@ public class GroupService( IDbContextFactory contextFactory, ILogger logger, IDiagnosticContext diagnosticContext, - CurrentUser currentUser) + CurrentUser currentUser, + IGroupMembershipNotificationService notificationService) { public async Task> GetGroupByIdAsync(Guid id, CancellationToken cancellationToken = default) { @@ -68,11 +69,17 @@ 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() .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 @@ -129,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); @@ -178,6 +209,64 @@ 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) + { + 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 + 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."); @@ -215,6 +304,13 @@ public async Task>> UpdateMembership(GroupMembershi return logger.LogAndReturnErrorResult(error); } + // 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; + var isPending = newStatus == MembershipStatus.PendingApprovalFromGroup; + membership.OwnershipLevel = membershipDto.OwnershipLevel; membership.PermissionLevel = membershipDto.PermissionLevel; membership.MembershipStatus = membershipDto.MembershipStatus; @@ -232,6 +328,14 @@ public async Task>> UpdateMembership(GroupMembershi "Det lykkedes ikke at opdatere medlemskabet. Prøv igen senere."); } + // 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) + { + var pendingCountChange = isPending ? 1 : -1; + await notificationService.NotifyAdminsOfPendingCountChangeAsync(membershipDto.GroupId, pendingCountChange, cancellationToken); + } + return new Success(); } 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/Groups/WebApplicationBuilderExtensions.cs b/src/web/Jordnaer/Features/Groups/WebApplicationBuilderExtensions.cs index 9daf816a..066898df 100644 --- a/src/web/Jordnaer/Features/Groups/WebApplicationBuilderExtensions.cs +++ b/src/web/Jordnaer/Features/Groups/WebApplicationBuilderExtensions.cs @@ -5,6 +5,8 @@ public static class WebApplicationBuilderExtensions public static WebApplicationBuilder AddGroupServices(this WebApplicationBuilder builder) { 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 65c9eabe..5344af04 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; @@ -14,12 +15,17 @@ public interface IMembershipService Task, Error>> RequestMembership( string groupName, CancellationToken cancellationToken = default); + + Task>> LeaveMembership( + string groupName, + CancellationToken cancellationToken = default); } public class MembershipService(CurrentUser currentUser, IDbContextFactory contextFactory, IEmailService emailService, - ILogger logger) : IMembershipService + ILogger logger, + IGroupMembershipNotificationService notificationService) : IMembershipService { public async Task, Error>> RequestMembership( string groupName, @@ -43,6 +49,37 @@ 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); + + // 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(); + } + return new Error(existingMembership.MembershipStatus); } @@ -60,7 +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 - 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(); } @@ -70,4 +125,47 @@ public async Task, Error>> Reques return new Error("Der skete en fejl. Prøv igen senere."); } } -} \ No newline at end of file + + 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."); + } + } +} 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 diff --git a/src/web/Jordnaer/Features/Posts/PostCardComponent.razor b/src/web/Jordnaer/Features/Posts/PostCardComponent.razor index 03557f2d..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 3d1c933d..fc845463 100644 --- a/src/web/Jordnaer/Features/Sharing/SocialShareButtons.razor +++ b/src/web/Jordnaer/Features/Sharing/SocialShareButtons.razor @@ -4,12 +4,12 @@
- - diff --git a/src/web/Jordnaer/Pages/Groups/GroupDetails.razor b/src/web/Jordnaer/Pages/Groups/GroupDetails.razor index 0067587d..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) {
+ @* Pending Requests Alert for Admins/Owners *@ + @if (_currentUserIsAdmin && _pendingRequestCount > 0) + { + +
+ + @_pendingRequestCount afventende medlemskabsanmodning@(_pendingRequestCount > 1 ? "er" : "") + + + Administrér medlemmer + +
+
+ } + @@ -213,12 +240,14 @@ private bool _hasPendingMembershipRequest = false; private bool _isMember = false; private bool _currentUserIsAdmin => _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; private bool _markerAdded; + private int _pendingRequestCount = 0; protected override async Task OnInitializedAsync() { @@ -237,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); @@ -250,6 +281,7 @@ _hasPendingMembershipRequest = currentUsersGroupMembership?.MembershipStatus is MembershipStatus.PendingApprovalFromGroup or MembershipStatus.PendingApprovalFromUser; + _currentUserIsOwner = currentUsersGroupMembership?.OwnershipLevel is OwnershipLevel.Owner; _admins = await GroupService .GetGroupMembersByPredicateAsync(x => @@ -257,6 +289,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; } @@ -275,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) 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..f0b371b2 100644 --- a/src/web/Jordnaer/Pages/Shared/TopBar.razor +++ b/src/web/Jordnaer/Pages/Shared/TopBar.razor @@ -1,6 +1,8 @@ @inject NavigationManager Navigation @inject UnreadMessageSignalRClient UnreadMessageSignalRClient @inject IChatService ChatService +@inject GroupService GroupService +@inject GroupMembershipSignalRClient GroupMembershipSignalRClient @inject CurrentUser CurrentUser @inject IJSRuntime JsRuntime @@ -19,10 +21,24 @@ Class="topbar-nav-link font-open-sans-semibold" Style="@JordnaerPalette.BlueBody.ToTextColor()"> Søg - - Grupper - + + + + + + Grupper + + + + + + Grupper + + + Grupper + + + @if (_pendingGroupRequestCount > 0) + { + + } + + @@ -167,6 +191,7 @@ @code { private int _unreadCount = 0; + private int _pendingGroupRequestCount = 0; private bool _isJsAvailable = false; private string? _baseTitle = null; @@ -180,6 +205,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 => { @@ -207,7 +236,19 @@ // 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(); } protected override async Task OnAfterRenderAsync(bool firstRender) @@ -261,6 +302,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(); diff --git a/tests/web/Jordnaer.Tests/Groups/GroupServiceTests.cs b/tests/web/Jordnaer.Tests/Groups/GroupServiceTests.cs index 1d30bfa0..b129ffe2 100644 --- a/tests/web/Jordnaer.Tests/Groups/GroupServiceTests.cs +++ b/tests/web/Jordnaer.Tests/Groups/GroupServiceTests.cs @@ -59,7 +59,8 @@ public GroupServiceTests(SqlServerContainer sqlServerContaine new ClaimsIdentity( [new Claim(ClaimTypes.NameIdentifier, _userProfileId)] )) - }); + }, + Substitute.For()); } [Fact]