Skip to content
12 changes: 12 additions & 0 deletions src/shared/Jordnaer.Shared/Groups/Events/GroupPostCreated.cs
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
namespace Jordnaer.Shared;

public class GroupMembershipStatusChanged
{
public required Guid GroupId { get; init; }
public int PendingCountChange { get; init; }
}
101 changes: 101 additions & 0 deletions src/web/Jordnaer/Consumers/GroupPostCreatedConsumer.cs
Original file line number Diff line number Diff line change
@@ -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<JordnaerDbContext> contextFactory,
ILogger<GroupPostCreatedConsumer> logger,
IPublishEndpoint publishEndpoint,
NavigationManager navigationManager) : IConsumer<GroupPostCreated>
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
{
public async Task Consume(ConsumeContext<GroupPostCreated> 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 $"""
<h4>Nyt opslag i din gruppe</h4>

<p><b>{authorName}</b> har oprettet et nyt opslag:</p>

<blockquote style="border-left: 3px solid #ccc; padding-left: 10px; color: #666;">
{postPreview}
</blockquote>

<p><a href="{groupUrl}">Klik her for at se opslaget</a></p>

{EmailConstants.Signature}
""";
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
53 changes: 53 additions & 0 deletions src/web/Jordnaer/Features/Dashboard/GroupsHub.razor
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
@page "/groups"

@inject GroupService GroupService
@inject GroupMembershipSignalRClient GroupMembershipSignalRClient
@inject NavigationManager Navigation

@implements IAsyncDisposable

<MetadataComponent Title="Grupper" Description="Opdag og administrer dine grupper" />

<MudLoading @bind-Loading="_isLoading" Darken Overlap>
Expand All @@ -13,6 +16,25 @@
Find grupper
</MudText>

@* Pending Requests Alert *@
@if (_totalPendingRequests > 0)
{
<MudAlert Severity="Severity.Warning" Variant="Variant.Outlined" Class="mb-4" NoIcon>
<div class="d-flex align-center justify-space-between flex-wrap gap-2">
<MudText Typo="Typo.body1">
<b>@_totalPendingRequests</b> afventende medlemskabsanmodning@(_totalPendingRequests > 1 ? "er" : "") i dine grupper
</MudText>
<MudButton Href="/groups/my-groups"
Variant="Variant.Filled"
Color="Color.Warning"
StartIcon="@Icons.Material.Filled.PersonAdd"
Size="Size.Small">
Administrér medlemmer
</MudButton>
</div>
</MudAlert>
}

@* Main Hub Cards *@
<MudGrid Spacing="4" Class="mb-6">

Expand Down Expand Up @@ -138,6 +160,7 @@
@code {
private bool _isLoading = true;
private List<UserGroupAccess> _recentGroups = new();
private int _totalPendingRequests = 0;

protected override async Task OnInitializedAsync()
{
Expand All @@ -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;
}

Expand All @@ -161,4 +209,9 @@
.Take(5)
.ToList();
}

public async ValueTask DisposeAsync()
{
await GroupMembershipSignalRClient.StopAsync();
}
}
4 changes: 2 additions & 2 deletions src/web/Jordnaer/Features/Email/EmailService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -75,9 +75,9 @@ public async Task SendMembershipRequestEmails(

var email = new SendEmail
{
Subject = "Ny medlemskabsanmodning",
Subject = $"Ny medlemskabsanmodning til {groupName}",
HtmlContent = $"""
<h4>Din gruppe har modtaget en ny medlemskabsanmodning</h4>
<h4>Din gruppe <b>{groupName}</b> har modtaget en ny medlemskabsanmodning</h4>

<a href="{groupMembershipUrl}">Klik her for at se den</a>

Expand Down
32 changes: 31 additions & 1 deletion src/web/Jordnaer/Features/GroupPosts/GroupPostService.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using Jordnaer.Database;
using Jordnaer.Shared;
using MassTransit;
using Microsoft.EntityFrameworkCore;
using OneOf;
using OneOf.Types;
Expand All @@ -8,7 +9,9 @@ namespace Jordnaer.Features.GroupPosts;



public class GroupPostService(IDbContextFactory<JordnaerDbContext> contextFactory)
public class GroupPostService(
IDbContextFactory<JordnaerDbContext> contextFactory,
IPublishEndpoint publishEndpoint)
{
public async Task<OneOf<GroupPostDto, NotFound>> GetPostAsync(Guid postId,
CancellationToken cancellationToken = default)
Expand Down Expand Up @@ -58,6 +61,33 @@ public async Task<OneOf<Success, Error<string>>> 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();
}

Expand Down
47 changes: 47 additions & 0 deletions src/web/Jordnaer/Features/Groups/GroupMembershipHub.cs
Original file line number Diff line number Diff line change
@@ -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<GroupMembershipHub> logger) : Hub<IGroupMembershipHub>
{
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<Guid> 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}");
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
}
35 changes: 35 additions & 0 deletions src/web/Jordnaer/Features/Groups/GroupMembershipSignalRClient.cs
Original file line number Diff line number Diff line change
@@ -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<AuthenticatedSignalRClientBase> logger,
NavigationManager navigationManager)
: AuthenticatedSignalRClientBase(logger, currentUser, navigationManager, "/hubs/group-membership")
{
public void OnMembershipStatusChanged(Func<GroupMembershipStatusChanged, Task> action)
{
if (HubConnection is null)
{
return;
}

HubConnection.Remove(nameof(IGroupMembershipHub.MembershipStatusChanged));
HubConnection.On(nameof(IGroupMembershipHub.MembershipStatusChanged), action);
}

public async Task JoinAdminGroupsAsync(List<Guid> groupIds)
{
if (HubConnection is null || !IsConnected)
{
return;
}

await HubConnection.InvokeAsync(nameof(GroupMembershipHub.JoinAdminGroups), groupIds);
}
}
Loading
Loading