Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion architecture.html

Large diffs are not rendered by default.

10 changes: 8 additions & 2 deletions architecture.json
Original file line number Diff line number Diff line change
Expand Up @@ -2725,7 +2725,7 @@
"Goals": 33,
"Habits": 74,
"Marketing": 4,
"Notifications": 12,
"Notifications": 13,
"Profile": 37,
"Referrals": 8,
"Social": 30,
Expand Down Expand Up @@ -2934,7 +2934,7 @@
"Goals": 38,
"Habits": 83,
"Marketing": 6,
"Notifications": 16,
"Notifications": 19,
"Profile": 39,
"Referrals": 13,
"Social": 32,
Expand Down Expand Up @@ -4935,6 +4935,11 @@
"Habit"
]
},
{
"testClass": "NotificationUrlsTests",
"file": "tests/Orbit.Application.Tests/Notifications/NotificationUrlsTests.cs",
"references": []
},
{
"testClass": "GetApiKeysQueryHandlerTests",
"file": "tests/Orbit.Application.Tests/Queries/ApiKeys/GetApiKeysQueryHandlerTests.cs",
Expand Down Expand Up @@ -7805,6 +7810,7 @@
"testClass": "PushNotificationServiceTests",
"file": "tests/Orbit.Infrastructure.Tests/Services/PushNotificationServiceTests.cs",
"references": [
"Notification",
"PushSubscription",
"User"
]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,6 @@ public class AcceptAccountabilityPairCommandHandler(
IUnitOfWork unitOfWork) : IRequestHandler<AcceptAccountabilityPairCommand, Result>
{
private const string BattleBuddyAchievementId = "battle_buddy";
private const string BuddyNotificationUrl = "/social?tab=buddies";

public async Task<Result> Handle(AcceptAccountabilityPairCommand request, CancellationToken cancellationToken)
{
var access = await socialAccessGuard.EnsureEnabledAsync(request.UserId, cancellationToken);
Expand Down Expand Up @@ -105,6 +103,6 @@ await xpAwarder.AwardAsync(
? $"{accepter.Name} aceitou seu convite de parceria."
: $"{accepter.Name} accepted your accountability invite.";

return Notification.Create(requester.Id, title, body, BuddyNotificationUrl);
return Notification.Create(requester.Id, title, body, null);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,6 @@ public partial class CheckInAccountabilityCommandHandler(
IUnitOfWork unitOfWork,
ILogger<CheckInAccountabilityCommandHandler> logger) : IRequestHandler<CheckInAccountabilityCommand, Result<Guid>>
{
private const string BuddyNotificationUrl = "/social?tab=buddies";

public async Task<Result<Guid>> Handle(CheckInAccountabilityCommand request, CancellationToken cancellationToken)
{
var access = await social.AccessGuard.EnsureEnabledAsync(request.UserId, cancellationToken);
Expand Down Expand Up @@ -104,7 +102,7 @@ private async Task<Result> ModerateNoteAsync(string? note, Guid checkerId, Cance
? $"{checker.Name} fez check-in com você."
: $"{checker.Name} checked in with you.";

return Notification.Create(buddy.Id, title, body, BuddyNotificationUrl);
return Notification.Create(buddy.Id, title, body, null);
}

[LoggerMessage(EventId = 1, Level = LogLevel.Warning, Message = "Check-in note moderation unavailable for user {UserId}; allowing note (fail open)")]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,6 @@ public class InviteAccountabilityBuddyCommandHandler(
SocialNotificationDispatcher notificationDispatcher,
IUnitOfWork unitOfWork) : IRequestHandler<InviteAccountabilityBuddyCommand, Result<Guid>>
{
private const string BuddyNotificationUrl = "/social?tab=buddies";

public async Task<Result<Guid>> Handle(InviteAccountabilityBuddyCommand request, CancellationToken cancellationToken)
{
var access = await socialAccessGuard.EnsureEnabledAsync(request.UserId, cancellationToken);
Expand Down Expand Up @@ -82,6 +80,6 @@ private static Notification BuildBuddyNotification(User buddy, User requester)
? $"{requester.Name} quer ser seu parceiro de responsabilidade."
: $"{requester.Name} wants to be your accountability buddy.";

return Notification.Create(buddy.Id, title, body, BuddyNotificationUrl);
return Notification.Create(buddy.Id, title, body, null);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
using Orbit.Application.Calendar.Queries;
using Orbit.Application.Calendar.Services;
using Orbit.Application.Common;
using Orbit.Application.Notifications;
using Orbit.Domain.Common;
using Orbit.Domain.Entities;
using Orbit.Domain.Enums;
Expand Down Expand Up @@ -265,7 +266,7 @@ private async Task HandleReconnectRequired(User user, string errorCode, Cancella
isPortuguese
? "A sincronização automática está pausada. Reconecte para retomar."
: "Auto-sync paused. Reconnect to resume.",
url: "/calendar-sync");
url: NotificationUrls.CalendarSync);
await deps.NotificationRepository.AddAsync(notification, ct);

await deps.UnitOfWork.SaveChangesAsync(ct);
Expand All @@ -276,7 +277,7 @@ private async Task<bool> HasRecentSuggestionNotification(Guid userId, DateTime u
var cutoff = utcNow - NotificationRateLimitWindow;
return await deps.NotificationRepository.AnyAsync(
n => n.UserId == userId
&& n.Url == "/calendar-sync?mode=review"
&& n.Url == NotificationUrls.CalendarSyncReview
&& n.CreatedAtUtc > cutoff,
ct);
}
Expand All @@ -296,7 +297,7 @@ private async Task CreateSuggestionNotification(User user, int count, Cancellati
user.Id,
title,
body,
url: "/calendar-sync?mode=review");
url: NotificationUrls.CalendarSyncReview);
await deps.NotificationRepository.AddAsync(notification, ct);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,8 @@ private async Task NotifyOneAsync(User requester, User user, SemaphoreSlim throt

using var scope = scopeFactory.CreateScope();
var pushNotificationService = scope.ServiceProvider.GetRequiredService<IPushNotificationService>();
await pushNotificationService.SendToUserAsync(user.Id, title, body, cancellationToken: cancellationToken);
await pushNotificationService.SendToUserAsync(
user.Id, title, body, url: null, cancellationToken: cancellationToken);
}
catch (Exception ex)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -654,7 +654,7 @@ private async Task QueueAchievementNotification(
: $"Achievement Unlocked: {name}";
var body = $"{description} (+{achievement.XpReward} XP)";

var notification = Notification.Create(userId, title, body);
var notification = Notification.Create(userId, title, body, null);
await repos.NotificationRepository.AddAsync(notification, ct);

pushes.Add(new PendingPush(userId, title, body));
Expand All @@ -673,7 +673,7 @@ private async Task QueueLevelUpNotification(
? $"Você alcançou {levelTitle}! Continue assim!"
: $"You've reached {newLevel.Title}! Keep going!";

var notification = Notification.Create(userId, title, body);
var notification = Notification.Create(userId, title, body, null);
await repos.NotificationRepository.AddAsync(notification, ct);

pushes.Add(new PendingPush(userId, title, body));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,12 @@ public async Task<Result<TestPushNotificationResponse>> Handle(TestPushNotificat

try
{
await pushService.SendToUserAsync(request.UserId, "Orbit Test", "Push notifications are working!", "/", cancellationToken);
await pushService.SendToUserAsync(
request.UserId,
"Orbit Test",
"Push notifications are working!",
NotificationUrls.Home,
cancellationToken);
return Result.Success(new TestPushNotificationResponse(subscriptionCount, "sent"));
}
catch (Exception exception) when (exception is not OperationCanceledException)
Expand Down
11 changes: 11 additions & 0 deletions src/Orbit.Application/Notifications/NotificationUrls.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
namespace Orbit.Application.Notifications;

public static class NotificationUrls
{
public const string Home = "/";
public const string Progress = "/progress";
Comment thread
thomasluizon marked this conversation as resolved.
public const string Chat = "/chat";
public const string Profile = "/profile";
public const string CalendarSync = "/calendar-sync";
public const string CalendarSyncReview = "/calendar-sync?mode=review";
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Orbit.Application.Common;
using Orbit.Application.Notifications;
using Orbit.Domain.Common;
using Orbit.Domain.Entities;
using Orbit.Domain.Enums;
Expand Down Expand Up @@ -171,11 +172,11 @@ private async Task SendNotification(User user, bool isReferrer, CancellationToke
var (title, body) = GetNotificationContent(isReferrer, isPt, user.IsPro);

await repos.NotificationRepository.AddAsync(
Notification.Create(user.Id, title, body, "/profile"), cancellationToken);
Notification.Create(user.Id, title, body, NotificationUrls.Profile), cancellationToken);

_ = Task.Run(async () =>
{
try { await pushNotificationService.SendToUserAsync(user.Id, title, body, "/profile", CancellationToken.None); }
try { await pushNotificationService.SendToUserAsync(user.Id, title, body, NotificationUrls.Profile, CancellationToken.None); }
catch (Exception ex) { LogReferralPushNotificationFailed(logger, ex, user.Id); }
}, CancellationToken.None);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,6 @@ public class AcceptFriendRequestCommandHandler(
private const string FirstFriendAchievementId = "first_friend";
private const string SquadGoalsAchievementId = "squad_goals";
private const int SquadGoalsThreshold = 5;
private const string FriendNotificationUrl = "/social?tab=friends";

public async Task<Result> Handle(AcceptFriendRequestCommand request, CancellationToken cancellationToken)
{
var access = await socialAccessGuard.EnsureEnabledAsync(request.UserId, cancellationToken);
Expand Down Expand Up @@ -119,6 +117,6 @@ await xpAwarder.AwardAsync(
? $"{accepter.Name} aceitou seu pedido de amizade."
: $"{accepter.Name} accepted your friend request.";

return Notification.Create(requester.Id, title, body, FriendNotificationUrl);
return Notification.Create(requester.Id, title, body, null);
}
}
4 changes: 1 addition & 3 deletions src/Orbit.Application/Social/Commands/SendCheerCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,6 @@ public partial class SendCheerCommandHandler(
ILogger<SendCheerCommandHandler> logger) : IRequestHandler<SendCheerCommand, Result<Guid>>
{
private const string CheerleaderAchievementId = "cheerleader";
private const string RecipientNotificationUrl = "/social?tab=feed";

public async Task<Result<Guid>> Handle(SendCheerCommand request, CancellationToken cancellationToken)
{
var access = await social.AccessGuard.EnsureEnabledAsync(request.UserId, cancellationToken);
Expand Down Expand Up @@ -153,7 +151,7 @@ private static Notification BuildRecipientNotification(User recipient, User send
? $"{sender.Name} torceu por você!"
: $"{sender.Name} cheered you on!";

return Notification.Create(recipient.Id, title, body, RecipientNotificationUrl);
return Notification.Create(recipient.Id, title, body, null);
}

[LoggerMessage(EventId = 1, Level = LogLevel.Warning, Message = "Cheer note moderation unavailable for sender {UserId}; allowing note (fail open)")]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,6 @@ public class SendFriendRequestCommandHandler(
SocialNotificationDispatcher notificationDispatcher,
IUnitOfWork unitOfWork) : IRequestHandler<SendFriendRequestCommand, Result<Guid>>
{
private const string RequestNotificationUrl = "/social?tab=friends";

public async Task<Result<Guid>> Handle(SendFriendRequestCommand request, CancellationToken cancellationToken)
{
var access = await socialAccessGuard.EnsureEnabledAsync(request.UserId, cancellationToken);
Expand Down Expand Up @@ -63,6 +61,6 @@ private static Notification BuildRequestNotification(User target, User requester
? $"{requester.Name} quer ser seu amigo."
: $"{requester.Name} wants to be your friend.";

return Notification.Create(target.Id, title, body, RequestNotificationUrl);
return Notification.Create(target.Id, title, body, null);
Comment thread
pullfrog[bot] marked this conversation as resolved.
}
}
13 changes: 12 additions & 1 deletion src/Orbit.Domain/Entities/Notification.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ public class Notification : Entity, ITimestamped, ISoftDeletable
public string Title { get; private set; } = null!;
public string Body { get; private set; } = null!;
public string? Url { get; private set; }
public string? DedupeKey { get; private set; }
public Guid? HabitId { get; private set; }
public bool IsRead { get; private set; }
public DateTime CreatedAtUtc { get; private set; }
Expand All @@ -17,14 +18,24 @@ public class Notification : Entity, ITimestamped, ISoftDeletable

private Notification() { }

public static Notification Create(Guid userId, string title, string body, string? url = null, Guid? habitId = null)
public static Notification Create(
Guid userId,
string title,
string body,
string? url = null,
Guid? habitId = null,
string? dedupeKey = null)
{
if (url is not null && (!url.StartsWith('/') || url.StartsWith("//", StringComparison.Ordinal)))
throw new ArgumentException("Notification URL must be an app-relative path.", nameof(url));

return new Notification
{
UserId = userId,
Title = title,
Body = body,
Url = url,
DedupeKey = dedupeKey,
HabitId = habitId,
IsRead = false,
CreatedAtUtc = DateTime.UtcNow,
Expand Down
Loading
Loading