Skip to content
Merged
Show file tree
Hide file tree
Changes from 19 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
cb9aa43
change /members back button to javascript back
NielsPilgaard Jan 2, 2026
05a455f
Delete 02-group-notifications.md
NielsPilgaard Jan 2, 2026
8e1099e
add padding to group tabs to fix text being cropped
NielsPilgaard Jan 2, 2026
39ae90e
fix possible NRE
NielsPilgaard Jan 2, 2026
2507cb7
add chat notification pref
NielsPilgaard Jan 2, 2026
2eba966
migrations
NielsPilgaard Jan 2, 2026
c3335ee
Update ChatNotificationServiceTests.cs
NielsPilgaard Jan 2, 2026
bfe8e46
Update Program.cs
NielsPilgaard Jan 2, 2026
3a6c46d
Create WebApplicationBuilderExtensions.cs
NielsPilgaard Jan 2, 2026
db4b56b
Update GroupMembership.cs
NielsPilgaard Jan 2, 2026
2fbca84
Update SendMessageConsumer.cs
NielsPilgaard Jan 2, 2026
59d7c2d
Update ChatNotificationService.cs
NielsPilgaard Jan 2, 2026
8874f97
Create NotificationSettingsService.cs
NielsPilgaard Jan 2, 2026
c1f5334
Create Notifications.razor
NielsPilgaard Jan 2, 2026
8c747f5
Update TopBar.razor
NielsPilgaard Jan 2, 2026
d4f5bae
Update ChatNotificationServiceTests.cs
NielsPilgaard Jan 2, 2026
5fc39eb
address review comments
NielsPilgaard Jan 2, 2026
487fee9
Update NotificationSettingsService.cs
NielsPilgaard Jan 2, 2026
f839c8f
Update ChatNotificationService.cs
NielsPilgaard Jan 2, 2026
2ea8568
Update ChatNotificationPreference.cs
NielsPilgaard Jan 2, 2026
21b834e
Update src/web/Jordnaer/Pages/Settings/Notifications.razor
NielsPilgaard Jan 2, 2026
28935b2
Update ChatNotificationServiceTests.cs
NielsPilgaard Jan 2, 2026
21f08b7
Merge branch 'feature/notification-settings' of https://github.com/Ni…
NielsPilgaard Jan 2, 2026
d1604db
Update Notifications.razor
NielsPilgaard Jan 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
using System.ComponentModel.DataAnnotations;
using NetEscapades.EnumGenerators;

namespace Jordnaer.Shared;

[EnumExtensions]
public enum ChatNotificationPreference
{
[Display(Name = "Ingen")]
None = 0,
Comment thread
NielsPilgaard marked this conversation as resolved.
Outdated

[Display(Name = "Kun første besked i ny samtale")]
FirstMessageOnly = 1,

[Display(Name = "Alle beskeder")]
AllMessages = 2
}
6 changes: 6 additions & 0 deletions src/shared/Jordnaer.Shared/Database/GroupMembership.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,10 @@ public class GroupMembership
public MembershipStatus MembershipStatus { get; set; }
public PermissionLevel PermissionLevel { get; set; } = PermissionLevel.None;
public OwnershipLevel OwnershipLevel { get; set; } = OwnershipLevel.None;

/// <summary>
/// Whether the user wants email notifications for new posts in this group.
/// Default is true.
/// </summary>
public bool EmailOnNewPost { get; set; } = true;
}
Comment thread
NielsPilgaard marked this conversation as resolved.
2 changes: 2 additions & 0 deletions src/shared/Jordnaer.Shared/Database/UserProfile.cs
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ public class UserProfile
public List<Group> Groups { get; set; } = [];
public List<GroupMembership> GroupMemberships { get; set; } = [];

public ChatNotificationPreference ChatNotificationPreference { get; set; } = ChatNotificationPreference.FirstMessageOnly;

Comment thread
NielsPilgaard marked this conversation as resolved.
public DateTime? DateOfBirth { get; set; }

public string ProfilePictureUrl { get; set; } = ProfileConstants.Default_Profile_Picture;
Expand Down
20 changes: 12 additions & 8 deletions src/web/Jordnaer/Consumers/GroupPostCreatedConsumer.cs
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
using Jordnaer.Database;
using Jordnaer.Extensions;
using Jordnaer.Features.Email;
using Jordnaer.Features.Metrics;
using Jordnaer.Shared;
using MassTransit;
using Microsoft.AspNetCore.Components;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using System.Net;
using System.Text.RegularExpressions;

Expand All @@ -14,7 +15,7 @@ public partial class GroupPostCreatedConsumer(
IDbContextFactory<JordnaerDbContext> contextFactory,
ILogger<GroupPostCreatedConsumer> logger,
IPublishEndpoint publishEndpoint,
NavigationManager navigationManager) : IConsumer<GroupPostCreated>
IOptions<AppOptions> appOptions) : IConsumer<GroupPostCreated>
{
public async Task Consume(ConsumeContext<GroupPostCreated> consumeContext)
{
Expand All @@ -29,18 +30,20 @@ public async Task Consume(ConsumeContext<GroupPostCreated> consumeContext)
{
await using var context = await contextFactory.CreateDbContextAsync(consumeContext.CancellationToken);

// Get all active members excluding the post author
var activeMembers = context.GroupMemberships
// Get all active members excluding the post author who have email notifications enabled
var activeMembers = await context.GroupMemberships
.AsNoTracking()
.Where(x => x.GroupId == message.GroupId &&
x.MembershipStatus == MembershipStatus.Active &&
x.UserProfileId != message.AuthorId)
.Select(x => x.UserProfileId);
x.UserProfileId != message.AuthorId &&
x.EmailOnNewPost)
.Select(x => x.UserProfileId)
.ToListAsync(consumeContext.CancellationToken);

// Get their email addresses
var emails = await context.Users
.AsNoTracking()
.Where(user => activeMembers.Any(userId => userId == user.Id) &&
.Where(user => activeMembers.Contains(user.Id) &&
!string.IsNullOrEmpty(user.Email))
.Select(user => new EmailRecipient
{
Expand All @@ -59,7 +62,8 @@ public async Task Consume(ConsumeContext<GroupPostCreated> consumeContext)
logger.LogInformation("Sending new post notification to {Count} members in group {GroupName}",
emails.Count, message.GroupName);

var groupUrl = $"{navigationManager.BaseUri}groups/{message.GroupName}";
var baseUrl = appOptions.Value.BaseUrl.TrimEnd('/');
var groupUrl = $"{baseUrl}/groups/{message.GroupName}";
var postPreview = GetPostPreview(message.PostText);

var email = new SendEmail
Expand Down
6 changes: 5 additions & 1 deletion src/web/Jordnaer/Consumers/SendMessageConsumer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ namespace Jordnaer.Consumers;
public class SendMessageConsumer(
JordnaerDbContext context,
ILogger<SendMessageConsumer> logger,
IHubContext<ChatHub, IChatHub> chatHub)
IHubContext<ChatHub, IChatHub> chatHub,
ChatNotificationService chatNotificationService)
Comment thread
NielsPilgaard marked this conversation as resolved.
: IConsumer<SendMessage>
{
public async Task Consume(ConsumeContext<SendMessage> consumeContext)
Expand Down Expand Up @@ -65,6 +66,9 @@ await context.Chats
throw;
}

// Send email notifications to users who want all messages
await chatNotificationService.NotifyRecipientsOfNewMessage(chatMessage, consumeContext.CancellationToken);

JordnaerMetrics.ChatMessagesSentCounter.Add(1);
}
}
102 changes: 86 additions & 16 deletions src/web/Jordnaer/Features/Chat/ChatNotificationService.cs
Original file line number Diff line number Diff line change
@@ -1,29 +1,44 @@
using Jordnaer.Consumers;
using Jordnaer.Database;
using Jordnaer.Extensions;
using Jordnaer.Features.Email;
using Jordnaer.Shared;
using MassTransit;
using Microsoft.AspNetCore.Hosting.Server;
using Microsoft.AspNetCore.Hosting.Server.Features;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;

namespace Jordnaer.Features.Chat;

public class ChatNotificationService(
IDbContextFactory<JordnaerDbContext> contextFactory,
ILogger<StartChatConsumer> logger,
IPublishEndpoint publishEndpoint,
IServer server) // TODO: Swap with NavigationManager
IOptions<AppOptions> options)
{
public async Task NotifyRecipients(StartChat startChat, CancellationToken cancellationToken = default)
{
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var recipientIds = startChat.Recipients.Select(recipient => recipient.Id);
var recipients = await context.Users
.AsNoTracking()
.Where(x => recipientIds.Contains(x.Id) && !string.IsNullOrEmpty(x.Email))
.ToDictionaryAsync(x => x.Id, x => x.Email!, cancellationToken);

// Get users with email and their notification preferences
var usersWithPreferences = await context.Users
.AsNoTracking()
.Where(x => recipientIds.Contains(x.Id) && !string.IsNullOrEmpty(x.Email))
.Join(context.UserProfiles,
user => user.Id,
profile => profile.Id,
(user, profile) => new { user.Id, user.Email, profile.ChatNotificationPreference })
.Where(x => x.ChatNotificationPreference == ChatNotificationPreference.FirstMessageOnly
|| x.ChatNotificationPreference == ChatNotificationPreference.AllMessages)
.ToListAsync(cancellationToken);

if (!usersWithPreferences.Any())
{
logger.LogInformation("No recipients want chat notifications for this message.");
return;
}

var recipients = usersWithPreferences.ToDictionary(x => x.Id, x => x.Email!);
var emailsToSend = CreateEmails(startChat, recipients);

await publishEndpoint.PublishBatch(emailsToSend, cancellationToken);
Expand Down Expand Up @@ -58,25 +73,80 @@ internal IEnumerable<SendEmail> CreateEmails(StartChat startChat, Dictionary<str

internal string GetChatLink(Guid chatId)
{
//TODO: Replace address look-up with config
var serverAddressFeature = server.Features.Get<IServerAddressesFeature>();
var serverAddress = serverAddressFeature?.Addresses.FirstOrDefault();

if (serverAddress is null)
{
logger.LogError("No addresses found in the IServerAddressFeature. A link to the chat cannot be created.");
}
var serverAddress = options.Value.BaseUrl?.TrimEnd('/');

return serverAddress is null
? $"https://mini-moeder.dk/chat/{chatId}"
: $"{serverAddress}/chat/{chatId}";
}

public async Task NotifyRecipientsOfNewMessage(SendMessage message, CancellationToken cancellationToken = default)
{
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);

// Get all recipients of this chat (excluding the sender)
var recipientIds = await context.UserChats
.Where(x => x.ChatId == message.ChatId && x.UserProfileId != message.SenderId)
.Select(x => x.UserProfileId)
.ToListAsync(cancellationToken);
Comment thread
NielsPilgaard marked this conversation as resolved.

// Get users who want "AllMessages" notifications
var usersWithPreferences = await context.Users
.AsNoTracking()
.Where(x => recipientIds.Contains(x.Id) && !string.IsNullOrEmpty(x.Email))
.Join(context.UserProfiles,
user => user.Id,
profile => profile.Id,
(user, profile) => new { user.Id, user.Email, User = user, profile.ChatNotificationPreference })
.Where(x => x.ChatNotificationPreference == ChatNotificationPreference.AllMessages)
.ToListAsync(cancellationToken);

if (!usersWithPreferences.Any())
{
logger.LogInformation("No recipients want all-message notifications for this chat.");
return;
}

// Get sender info
var sender = await context.UserProfiles
.AsNoTracking()
.FirstOrDefaultAsync(x => x.Id == message.SenderId, cancellationToken);

if (sender is null)
{
logger.LogWarning("Sender profile not found for message notification.");
return;
}

var chatLink = GetChatLink(message.ChatId);
var emailsToSend = new List<SendEmail>();

foreach (var userWithPref in usersWithPreferences)
{
var recipientEmailAddress = new EmailRecipient
{
Email = userWithPref.Email!,
DisplayName = userWithPref.User.UserName ?? userWithPref.Email!
};

emailsToSend.Add(new SendEmail
{
To = [recipientEmailAddress],
Subject = $"Ny besked fra {sender.DisplayName}",
HtmlContent = CreateNewChatEmailMessage(recipientEmailAddress.DisplayName, sender.DisplayName, chatLink)
});
}

await publishEndpoint.PublishBatch(emailsToSend, cancellationToken);

logger.LogInformation("Sent {Count} emails for new chat message.", emailsToSend.Count);
}

private static string CreateNewChatEmailMessage(string recipientDisplayName,
string messageSenderDisplayName,
string link) => $"""
{EmailConstants.Greeting(recipientDisplayName)}

<p>Du har fået en ny besked fra <b>{messageSenderDisplayName}</b></p>

<p>Hvis du vil gå direkte til beskeden, kan du klikke på linket nedenfor:</p>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
using Jordnaer.Database;
using Jordnaer.Shared;
using Microsoft.EntityFrameworkCore;
using OneOf;
using OneOf.Types;

namespace Jordnaer.Features.Notifications;

public interface INotificationSettingsService
{
Task<ChatNotificationPreference> GetChatPreferenceAsync(string userId, CancellationToken cancellationToken = default);
Task<OneOf<Success, NotFound>> SetChatPreferenceAsync(string userId, ChatNotificationPreference preference, CancellationToken cancellationToken = default);
Task<OneOf<Success, NotFound>> SetGroupPostPreferenceAsync(string userId, Guid groupId, bool enabled, CancellationToken cancellationToken = default);
Task<OneOf<Success, NotFound>> SetAllGroupPostPreferencesAsync(string userId, bool enabled, CancellationToken cancellationToken = default);
Task<List<GroupMembership>> GetGroupPreferencesAsync(string userId, CancellationToken cancellationToken = default);
}

public class NotificationSettingsService(IDbContextFactory<JordnaerDbContext> contextFactory) : INotificationSettingsService
{
public async Task<ChatNotificationPreference> GetChatPreferenceAsync(string userId, CancellationToken cancellationToken = default)
{
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);

var userProfile = await context.UserProfiles
.AsNoTracking()
.FirstOrDefaultAsync(x => x.Id == userId, cancellationToken);

return userProfile?.ChatNotificationPreference ?? ChatNotificationPreference.FirstMessageOnly;
}

public async Task<OneOf<Success, NotFound>> SetChatPreferenceAsync(string userId, ChatNotificationPreference preference, CancellationToken cancellationToken = default)
{
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);

var userProfile = await context.UserProfiles
.FirstOrDefaultAsync(x => x.Id == userId, cancellationToken);

if (userProfile is null)
{
return new NotFound();
}

userProfile.ChatNotificationPreference = preference;
await context.SaveChangesAsync(cancellationToken);
return new Success();
}

public async Task<OneOf<Success, NotFound>> SetGroupPostPreferenceAsync(string userId, Guid groupId, bool enabled, CancellationToken cancellationToken = default)
{
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);

var membership = await context.GroupMemberships
.FirstOrDefaultAsync(x => x.UserProfileId == userId && x.GroupId == groupId, cancellationToken);

if (membership is null)
{
return new NotFound();
}

membership.EmailOnNewPost = enabled;
await context.SaveChangesAsync(cancellationToken);
return new Success();
}

public async Task<OneOf<Success, NotFound>> SetAllGroupPostPreferencesAsync(string userId, bool enabled, CancellationToken cancellationToken = default)
{
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);

var rowsAffected = await context.GroupMemberships
.Where(x => x.UserProfileId == userId && x.MembershipStatus == MembershipStatus.Active)
.ExecuteUpdateAsync(setters => setters.SetProperty(m => m.EmailOnNewPost, enabled), cancellationToken);

return rowsAffected > 0 ? new Success() : new NotFound();
}

public async Task<List<GroupMembership>> GetGroupPreferencesAsync(string userId, CancellationToken cancellationToken = default)
{
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);

// Get all active memberships with group information
return await context.GroupMemberships
.AsNoTracking()
.Where(x => x.UserProfileId == userId && x.MembershipStatus == MembershipStatus.Active)
.Include(x => x.Group)
.ToListAsync(cancellationToken);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
namespace Jordnaer.Features.Notifications;

public static class WebApplicationBuilderExtensions
{
public static WebApplicationBuilder AddNotificationServices(this WebApplicationBuilder builder)
{
builder.Services.AddScoped<INotificationSettingsService, NotificationSettingsService>();
return builder;
}
}
Loading
Loading