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
+
+
+