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.

11 changes: 10 additions & 1 deletion architecture.json
Original file line number Diff line number Diff line change
Expand Up @@ -2934,7 +2934,7 @@
"Goals": 54,
"Habits": 87,
"Marketing": 6,
"Notifications": 19,
"Notifications": 20,
"Profile": 40,
"Referrals": 13,
"Social": 35,
Expand Down Expand Up @@ -7780,6 +7780,15 @@
"User"
]
},
{
"testClass": "PeriodCloseNotificationServiceTests",
"file": "tests/Orbit.Infrastructure.Tests/Services/PeriodCloseNotificationServiceTests.cs",
"references": [
"Habit",
"PushSubscription",
"User"
]
},
{
"testClass": "PlayNotificationCleanupServiceTests",
"file": "tests/Orbit.Infrastructure.Tests/Services/PlayNotificationCleanupServiceTests.cs",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ internal static void AddInProcessSchedulers(WebApplicationBuilder builder)
{
builder.Services.AddHostedService<ReminderSchedulerService>();
builder.Services.AddHostedService<GoalDeadlineNotificationService>();
if (IsPeriodCloseNotificationEnabled(builder.Configuration))
builder.Services.AddHostedService<PeriodCloseNotificationService>();
builder.Services.AddHostedService<SlipAlertSchedulerService>();
builder.Services.AddHostedService<ProactiveCheckinSchedulerService>();
builder.Services.AddHostedService<AccountDeletionService>();
Expand Down Expand Up @@ -93,6 +95,8 @@ internal static void AddDurableRecurringJobs(WebApplicationBuilder builder)
builder.Services.AddSingleton<ScheduledJobRunner>();
AddScheduledJob<ReminderSchedulerService>(builder);
AddScheduledJob<GoalDeadlineNotificationService>(builder);
if (IsPeriodCloseNotificationEnabled(builder.Configuration))
AddScheduledJob<PeriodCloseNotificationService>(builder);
AddScheduledJob<SlipAlertSchedulerService>(builder);
AddScheduledJob<ProactiveCheckinSchedulerService>(builder);
AddScheduledJob<AccountDeletionService>(builder);
Expand Down Expand Up @@ -122,4 +126,7 @@ private static void AddScheduledJob<TJob>(WebApplicationBuilder builder)
/// </summary>
internal static bool IsStreakFreezeAutoActivationEnabled(IConfiguration configuration) =>
configuration.GetValue("BackgroundServices:StreakFreezeAutoActivationEnabled", true);

internal static bool IsPeriodCloseNotificationEnabled(IConfiguration configuration) =>
configuration.GetValue("BackgroundServices:PeriodCloseNotificationEnabled", false);
}
2 changes: 2 additions & 0 deletions src/Orbit.Api/appsettings.json
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,8 @@
"BackgroundServices": {
"ReminderIntervalMinutes": 1,
"GoalDeadlineIntervalMinutes": 30,
"PeriodCloseNotificationEnabled": false,
"PeriodCloseNotificationIntervalMinutes": 30,
"SlipAlertIntervalMinutes": 5,
"ProactiveCheckinIntervalMinutes": 60,
"ProactiveCheckinHour": 19,
Expand Down
3 changes: 3 additions & 0 deletions src/Orbit.Application/Notifications/NotificationUrls.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,7 @@ public static class NotificationUrls
public const string Profile = "/profile";
public const string CalendarSync = "/calendar-sync";
public const string CalendarSyncReview = "/calendar-sync?mode=review";

public static string WrappedClosedMonth(int year, int month) =>
$"/progress?wrapped=month&year={year}&month={month}";
}
2 changes: 2 additions & 0 deletions src/Orbit.Domain/Interfaces/IPushNotificationService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,6 @@ namespace Orbit.Domain.Interfaces;
public interface IPushNotificationService
{
Task SendToUserAsync(Guid userId, string title, string body, string? url = null, CancellationToken cancellationToken = default);

Task<bool> TrySendToUserAsync(Guid userId, string title, string body, string? url = null, CancellationToken cancellationToken = default);
}
263 changes: 263 additions & 0 deletions src/Orbit.Infrastructure/Services/PeriodCloseNotificationService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,263 @@
using System.Globalization;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Orbit.Application.Common;
using Orbit.Application.Notifications;
using Orbit.Domain.Entities;
using Orbit.Domain.Interfaces;
using Orbit.Infrastructure.BackgroundJobs;
using Orbit.Infrastructure.Persistence;
using Orbit.Infrastructure.Services.Hosting;

namespace Orbit.Infrastructure.Services;

public partial class PeriodCloseNotificationService(
IServiceScopeFactory scopeFactory,
ILogger<PeriodCloseNotificationService> logger,
IConfiguration configuration) : ScheduledServiceBase, IScheduledJob
{
private readonly TimeSpan _interval = TimeSpan.FromMinutes(
configuration.GetValue("BackgroundServices:PeriodCloseNotificationIntervalMinutes", 30));

public string Name => "period-close-notification";

public string CronExpression => "*/30 * * * *";

public Task RunAsync(CancellationToken cancellationToken) => ExecuteTickAsync(cancellationToken);

protected override TimeSpan Interval => _interval;

protected override async Task ExecuteTickAsync(CancellationToken stoppingToken)
{
await CheckAndSendNotificationsAsync(stoppingToken);
BackgroundServiceHealthCheck.RecordTick("PeriodCloseNotification");
}

protected override void LogStarted() => LogServiceStarted(logger);

protected override void LogStopped() => LogServiceStopped(logger);

protected override void LogTickError(Exception ex) => LogServiceError(logger, ex);

internal async Task CheckAndSendNotificationsAsync(CancellationToken cancellationToken)
{
using var scope = scopeFactory.CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService<OrbitDbContext>();
var pushService = scope.ServiceProvider.GetRequiredService<IPushNotificationService>();
var userDateService = scope.ServiceProvider.GetRequiredService<IUserDateService>();

var subscribedUsers = await dbContext.Users
.AsNoTracking()
.Where(user => !user.IsDeactivated
&& dbContext.PushSubscriptions.Any(subscription => subscription.UserId == user.Id))
.Select(user => new SubscribedUser(user.Id, user.TimeZone, user.Language))
.ToListAsync(cancellationToken);

var boundaryUsers = new List<BoundaryUser>();
foreach (var user in subscribedUsers)
{
var userToday = await userDateService.GetUserTodayAsync(
user.TimeZone,
user.Id,
cancellationToken);
if (userToday.Day != 1)
continue;

var closedMonth = userToday.AddMonths(-1);
boundaryUsers.Add(new BoundaryUser(user.Id, user.Language, closedMonth.Year, closedMonth.Month));
}

foreach (var monthGroup in boundaryUsers.GroupBy(user => new { user.Year, user.Month }))
{
await ProcessClosedMonthAsync(
monthGroup.ToList(),
monthGroup.Key.Year,
monthGroup.Key.Month,
dbContext,
pushService,
cancellationToken);
}
}

private async Task ProcessClosedMonthAsync(
List<BoundaryUser> users,
int year,
int month,
OrbitDbContext dbContext,
IPushNotificationService pushService,
CancellationToken cancellationToken)
{
var dateFrom = new DateOnly(year, month, 1);
var dateTo = new DateOnly(year, month, DateTime.DaysInMonth(year, month));
var userIds = users.Select(user => user.Id).ToList();

var activeUserIds = (await dbContext.Habits
.IgnoreQueryFilters()
.AsNoTracking()
.Where(habit => userIds.Contains(habit.UserId)
&& habit.Logs.Any(log => !log.IsDeleted
&& log.Value > 0
&& log.Date >= dateFrom
&& log.Date <= dateTo))
.Select(habit => habit.UserId)
.Distinct()
.ToListAsync(cancellationToken))
.ToHashSet();

var activeUsers = users.Where(user => activeUserIds.Contains(user.Id)).ToList();
if (activeUsers.Count == 0)
return;

var dedupeKeys = activeUsers
.Select(user => BuildDedupeKey(user.Id, year, month))
.ToList();
var sentKeys = (await dbContext.Notifications
.Where(notification => dedupeKeys.Contains(notification.DedupeKey!))
.Select(notification => notification.DedupeKey!)
.ToListAsync(cancellationToken))
.ToHashSet();

foreach (var user in activeUsers)
{
var dedupeKey = BuildDedupeKey(user.Id, year, month);
if (!sentKeys.Add(dedupeKey))
continue;

try
{
await TryRecordAndSendAsync(
user,
year,
month,
dedupeKey,
dbContext,
pushService,
cancellationToken);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
LogUserProcessingFailed(logger, user.Id, year, month, ex);
}
}
}

private async Task TryRecordAndSendAsync(
BoundaryUser user,
int year,
int month,
string dedupeKey,
OrbitDbContext dbContext,
IPushNotificationService pushService,
CancellationToken cancellationToken)
{
var (title, body) = BuildNotification(month, user.Language);
var url = NotificationUrls.WrappedClosedMonth(year, month);
var notification = Notification.Create(
user.Id,
title,
body,
url,
dedupeKey: dedupeKey);
await dbContext.Notifications.AddAsync(notification, cancellationToken);

try
{
await dbContext.SaveChangesAsync(cancellationToken);
Comment thread
thomasluizon marked this conversation as resolved.
}
catch (DbUpdateException ex) when (DbUniqueViolation.IsUniqueViolation(ex))
{
dbContext.Entry(notification).State = EntityState.Detached;
if (logger.IsEnabled(LogLevel.Debug))
LogNotificationAlreadyRecorded(logger, user.Id, year, month);
return;
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
dbContext.Entry(notification).State = EntityState.Detached;
LogNotificationRecordFailed(logger, user.Id, year, month, ex);
return;
}

try
{
var delivered = await pushService.TrySendToUserAsync(user.Id, title, body, url, cancellationToken);
if (!delivered)
{
await RemoveNotificationAfterPushFailureAsync(notification, dbContext);
LogNotificationNotDelivered(logger, user.Id, year, month);
return;
}
}
catch
{
await RemoveNotificationAfterPushFailureAsync(notification, dbContext);
throw;
}

if (logger.IsEnabled(LogLevel.Debug))
LogNotificationSent(logger, user.Id, year, month);
}

private async Task RemoveNotificationAfterPushFailureAsync(
Notification notification,
OrbitDbContext dbContext)
{
try
{
dbContext.Notifications.Remove(notification);
await dbContext.SaveChangesAsync(CancellationToken.None);
}
catch (Exception ex)
{
dbContext.Entry(notification).State = EntityState.Detached;
LogNotificationReleaseFailed(logger, notification.UserId, notification.DedupeKey!, ex);
}
}

internal static string BuildDedupeKey(Guid userId, int year, int month) =>
$"wrapped-{userId}-{year}-{month:D2}";

internal static (string Title, string Body) BuildNotification(int month, string? language)
{
var isPortuguese = LocaleHelper.IsPortuguese(language);
var culture = CultureInfo.GetCultureInfo(isPortuguese ? "pt-BR" : "en-US");
var monthName = culture.TextInfo.ToTitleCase(culture.DateTimeFormat.GetMonthName(month));

return isPortuguese
? ("Seu Wrapped está pronto", $"{monthName} fechou - veja como foi o seu mês.")
: ("Your Wrapped is ready", $"{monthName} is closed - see how your month went.");
}

private sealed record SubscribedUser(Guid Id, string? TimeZone, string? Language);

private sealed record BoundaryUser(Guid Id, string? Language, int Year, int Month);

[LoggerMessage(EventId = 1, Level = LogLevel.Information, Message = "PeriodCloseNotificationService started")]
private static partial void LogServiceStarted(ILogger logger);

[LoggerMessage(EventId = 2, Level = LogLevel.Information, Message = "PeriodCloseNotificationService stopped")]
private static partial void LogServiceStopped(ILogger logger);

[LoggerMessage(EventId = 3, Level = LogLevel.Error, Message = "Error in period close notification service")]
private static partial void LogServiceError(ILogger logger, Exception ex);

[LoggerMessage(EventId = 4, Level = LogLevel.Debug, Message = "Sent closed month notification for user {UserId} and period {Year}-{Month}")]
private static partial void LogNotificationSent(ILogger logger, Guid userId, int year, int month);

[LoggerMessage(EventId = 5, Level = LogLevel.Debug, Message = "Closed month notification already recorded for user {UserId} and period {Year}-{Month}")]
private static partial void LogNotificationAlreadyRecorded(ILogger logger, Guid userId, int year, int month);

[LoggerMessage(EventId = 6, Level = LogLevel.Error, Message = "Failed to record closed month notification for user {UserId} and period {Year}-{Month}")]
private static partial void LogNotificationRecordFailed(ILogger logger, Guid userId, int year, int month, Exception ex);

[LoggerMessage(EventId = 7, Level = LogLevel.Error, Message = "Failed to process closed month notification for user {UserId} and period {Year}-{Month}")]
private static partial void LogUserProcessingFailed(ILogger logger, Guid userId, int year, int month, Exception ex);

[LoggerMessage(EventId = 8, Level = LogLevel.Error, Message = "Failed to release closed month notification claim {DedupeKey} for user {UserId} after push failure")]
private static partial void LogNotificationReleaseFailed(ILogger logger, Guid userId, string dedupeKey, Exception ex);

[LoggerMessage(EventId = 9, Level = LogLevel.Warning, Message = "No push delivery succeeded for user {UserId} and closed period {Year}-{Month}")]
private static partial void LogNotificationNotDelivered(ILogger logger, Guid userId, int year, int month);
}
Loading
Loading