Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -16,5 +16,8 @@ public enum MembershipStatus
PendingApprovalFromUser = 2,

[Display(Name = "Afvist")]
Rejected = 3
Rejected = 3,

[Display(Name = "Forladt")]
Left = 4
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
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;
}
18 changes: 18 additions & 0 deletions src/shared/Jordnaer.Shared/Groups/GroupMemberSlim.cs
Original file line number Diff line number Diff line change
@@ -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;
}
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; }
}
26 changes: 14 additions & 12 deletions src/web/Jordnaer/Components/CookieBanner.razor
Original file line number Diff line number Diff line change
Expand Up @@ -9,18 +9,20 @@
bool _hideBanner = false;
async Task Consent() => await LocalStorage.SetItemAsync(CookieBannerId, true);

RenderFragment _banner =
(@<div>
<MudText Typo="Typo.body2">
Mini Møder anvender cookies for at forbedre din oplevelse og for at hjælpe os med at forstå,
hvordan vores hjemmeside bliver brugt.
</MudText>
<MudText Class="mt-3" Typo="Typo.body2">
Du kan læse mere i vores
<MudLink Color="Color.Inherit" Href="/privacy" Target="_blank" Typo="Typo.body2" Underline="Underline.Always"><b>Privatlivspolitik</b></MudLink> og
<MudLink Color="Color.Inherit" Href="/terms" Target="_blank" Typo="Typo.body2" Underline="Underline.Always"><b>Servicevilkår</b></MudLink>.
</MudText>
</div>);
readonly RenderFragment _banner = __builder =>
{
<div>
<MudText Typo="Typo.body2" Style="@($"color: {JordnaerPalette.WarmWhite};")">
Mini Møder anvender cookies for at forbedre din oplevelse og for at hjælpe os med at forstå,
hvordan vores hjemmeside bliver brugt.
</MudText>
<MudText Class="mt-3" Typo="Typo.body2" Style="@($"color: {JordnaerPalette.WarmWhite};")">
Du kan læse mere i vores
<MudLink Color="Color.Inherit" Href="/privacy" Target="_blank" Typo="Typo.body2" Underline="Underline.Always"><b>Privatlivspolitik</b></MudLink> og
<MudLink Color="Color.Inherit" Href="/terms" Target="_blank" Typo="Typo.body2" Underline="Underline.Always"><b>Servicevilkår</b></MudLink>.
</MudText>
</div>
};

protected override async Task OnAfterRenderAsync(bool firstRender)
{
Expand Down
123 changes: 123 additions & 0 deletions src/web/Jordnaer/Consumers/GroupPostCreatedConsumer.cs
Original file line number Diff line number Diff line change
@@ -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<JordnaerDbContext> contextFactory,
ILogger<GroupPostCreatedConsumer> logger,
IPublishEndpoint publishEndpoint,
NavigationManager navigationManager) : IConsumer<GroupPostCreated>
{
public async Task Consume(ConsumeContext<GroupPostCreated> 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 <br/> tags for proper display after encoding
encodedPostPreview = encodedPostPreview.Replace("\r\n", "<br/>").Replace("\n", "<br/>");

var encodedGroupUrl = WebUtility.HtmlEncode(groupUrl);

return $"""
<h4>Nyt opslag i din gruppe</h4>

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

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

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

{EmailConstants.Signature}
""";
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

[GeneratedRegex("<.*?>")]
private static partial Regex HtmlTagsRegex();
}
35 changes: 35 additions & 0 deletions src/web/Jordnaer/Features/Dashboard/GroupsHub.razor
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
@inject GroupService GroupService
@inject NavigationManager Navigation

@implements IAsyncDisposable

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

<MudLoading @bind-Loading="_isLoading" Darken Overlap>
Expand All @@ -13,6 +15,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 +159,7 @@
@code {
private bool _isLoading = true;
private List<UserGroupAccess> _recentGroups = new();
private int _totalPendingRequests = 0;

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

Expand All @@ -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;
}
}
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
42 changes: 41 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,10 @@ namespace Jordnaer.Features.GroupPosts;



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

Expand Down
14 changes: 12 additions & 2 deletions src/web/Jordnaer/Features/Groups/GroupMemberListComponent.razor
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,17 @@ else
<MudAvatar Size="Size.Large" Class="mr-3">
<MudImage Src="@member.ProfilePictureUrl" loading="lazy" Alt="Avatar" />
</MudAvatar>
<MudText>@member.DisplayName.Sanitize()</MudText>
<div class="d-flex align-center gap-2">
<MudText>@member.DisplayName.Sanitize()</MudText>
@if (member.OwnershipLevel == OwnershipLevel.Owner)
{
<MudChip Size="Size.Small" Color="Color.Primary" Variant="Variant.Filled">Ejer</MudChip>
}
else if (member.PermissionLevel == PermissionLevel.Admin)
{
<MudChip Size="Size.Small" Color="Color.Info" Variant="Variant.Outlined">Admin</MudChip>
}
</div>
</MudListItem>
}
</MudList>
Expand All @@ -40,5 +50,5 @@ else
public bool CurrentUserIsAdmin { get; set; } = false;

[Parameter, EditorRequired]
public required List<UserSlim>? GroupMembers { get; set; }
public required List<GroupMemberSlim>? GroupMembers { get; set; }
}
Loading