Commit: eb449e4
Parent: b8d5c39

Notificaiton service

Mårten Åsberg committed on 2026-05-05 at 17:38
Directory.Packages.props +9 -5
diff --git a/Directory.Packages.props b/Directory.Packages.props
index ce397d0..a3c54ae 100644
@@ -11,19 +11,23 @@
</ItemGroup>
<ItemGroup>
<PackageVersion Include="BCrypt.Net-Next" Version="4.1.0" />
<PackageVersion Include="Google.Protobuf" Version="3.34.1" />
<PackageVersion Include="Grpc.Net.ClientFactory" Version="2.80.0" />
<PackageVersion Include="Grpc.Tools" Version="2.80.0">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageVersion>
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.7">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageVersion>
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.7" />
<PackageVersion Include="Microsoft.Extensions.Http.Resilience" Version="10.5.0" />
<PackageVersion Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.15.3" />
<PackageVersion Include="OpenTelemetry.Extensions.Hosting" Version="1.15.3" />
<PackageVersion Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.15.2" />
<PackageVersion
Include="OpenTelemetry.Instrumentation.EntityFrameworkCore"
Version="1.15.1-beta.1"
/>
<PackageVersion Include="OpenTelemetry.Instrumentation.EntityFrameworkCore" Version="1.15.1-beta.1" />
<PackageVersion Include="OpenTelemetry.Instrumentation.Http" Version="1.15.1" />
<PackageVersion Include="OpenTelemetry.Instrumentation.Runtime" Version="1.15.1" />
</ItemGroup>
</Project>
</Project>
\ No newline at end of file
MatDenDagen/Components/Pages/Admin/Date.razor +1 -1
diff --git a/MatDenDagen/Components/Pages/Admin/Date.razor b/MatDenDagen/Components/Pages/Admin/Date.razor
index 05062ab..6a2f5d0 100644
@@ -77,7 +77,7 @@ else
if (currentDateSpan != null)
{
dateSpanModel = new()
dateSpanModel ??= new()
{
Start = currentDateSpan.Start.ToString(),
End = currentDateSpan.End.ToString()
MatDenDagen/MatDenDagen.csproj +10 -0
diff --git a/MatDenDagen/MatDenDagen.csproj b/MatDenDagen/MatDenDagen.csproj
index 986c9ec..5d6dc35 100644
@@ -5,12 +5,22 @@
<RequiresAspNetWebAssets>true</RequiresAspNetWebAssets>
</PropertyGroup>
<ItemGroup>
<Protobuf Include="Protos\sms.proto" GrpcServices="Client" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="BCrypt.Net-Next" />
<PackageReference Include="Google.Protobuf" />
<PackageReference Include="Grpc.Net.ClientFactory" />
<PackageReference Include="Grpc.Tools">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Design">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" />
<PackageReference Include="Microsoft.Extensions.Http.Resilience" />
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" />
<PackageReference Include="OpenTelemetry.Extensions.Hosting" />
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" />
MatDenDagen/Program.cs +9 -1
diff --git a/MatDenDagen/Program.cs b/MatDenDagen/Program.cs
index f6762c4..35323ec 100644
@@ -14,7 +14,15 @@ var builder = WebApplication.CreateBuilder(args);
builder.ConfigureOpenTelemetry();
builder.Services.AddStorageServices().AddAdminService().AddDateService().AddExportService();
builder.Services.ConfigureHttpClientDefaults(http => http.AddStandardResilienceHandler());
builder
.Services.AddStorageServices()
.AddAdminService()
.AddDateService()
.AddExportService()
.AddSmsSenderClient()
.AddNotificationService();
builder.Services.AddRazorComponents();
MatDenDagen/Protos/sms.proto +28 -0
diff --git a/MatDenDagen/Protos/sms.proto b/MatDenDagen/Protos/sms.proto
new file mode 100644
index 0000000..176cbdd
@@ -0,0 +1,28 @@
syntax = "proto3";
option csharp_namespace = "HuaweiWifiSms.Grpc";
package sms;
service SmsSender {
// Sends a single SMS to a single recipient.
rpc SendSms (SmsRequest) returns (SmsResponse);
}
message SmsRequest {
// required
optional string recipient_phone_number = 1;
// required
optional string content = 2;
}
message SmsResponse {
// required
optional SmsStatus status = 1;
}
enum SmsStatus {
Unknown = 0;
Success = 1;
Failure = 2;
}
MatDenDagen/Services/NotificationBackgroundService.cs +64 -0
diff --git a/MatDenDagen/Services/NotificationBackgroundService.cs b/MatDenDagen/Services/NotificationBackgroundService.cs
new file mode 100644
index 0000000..22429e2
@@ -0,0 +1,64 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace MatDenDagen.Services;
public sealed class NotificationBackgroundService(
IServiceProvider serviceProvider,
ILogger<NotificationBackgroundService> logger,
IOptions<NotificationServiceOptions> options
) : BackgroundService
{
private readonly TimeSpan checkInterval = options.Value.CheckInterval;
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
logger.LogInformation(
"Notification background service started with {Interval} minute interval",
checkInterval.TotalMinutes
);
using var timer = new PeriodicTimer(checkInterval);
try
{
DateTimeOffset? lastCheck = null;
do
{
lastCheck = await ExecuteNotificationCheckAsync(lastCheck, stoppingToken);
} while (await timer.WaitForNextTickAsync(stoppingToken));
}
catch (OperationCanceledException)
{
logger.LogInformation("Notification background service is stopping");
}
catch (Exception ex)
{
logger.LogError(ex, "Notification background service failed");
}
}
private async Task<DateTimeOffset?> ExecuteNotificationCheckAsync(
DateTimeOffset? lastCheck,
CancellationToken cancellationToken
)
{
try
{
using var scope = serviceProvider.CreateScope();
var notificationService = scope.ServiceProvider.GetRequiredService<NotificationService>();
return await notificationService.CheckAndSendNotificationsAsync(lastCheck, cancellationToken);
}
catch (Exception ex)
{
logger.LogError(ex, "Error during notification check");
return null;
}
}
}
MatDenDagen/Services/NotificationService.cs +96 -0
diff --git a/MatDenDagen/Services/NotificationService.cs b/MatDenDagen/Services/NotificationService.cs
new file mode 100644
index 0000000..aa38ea6
@@ -0,0 +1,96 @@
using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using HuaweiWifiSms.Grpc;
using MatDenDagen.Infrastructure.Storage.Database;
using MatDenDagen.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace MatDenDagen.Services;
public sealed class NotificationService(
ILogger<NotificationService> logger,
IOptions<NotificationServiceOptions> options,
DateService dateService,
QuestionnaireContext questionnaireContext,
SmsSender.SmsSenderClient smsClient,
TimeProvider timeProvider
)
{
private readonly TimeSpan interval = options.Value.CheckInterval;
public async Task<DateTimeOffset> CheckAndSendNotificationsAsync(
DateTimeOffset? lastCheck,
CancellationToken cancellationToken
)
{
var utcNow = timeProvider.GetUtcNow();
lastCheck ??= utcNow - interval;
logger.LogDebug("Checking for notifications between {LastCheck} and {Now}", lastCheck, utcNow);
var dateConfig = await dateService.GetDateConfigAsync();
if (dateConfig?.TheDay is not DateOnly theDay)
{
logger.LogDebug("No date configured in database, cannot send notifications");
return utcNow;
}
var eventDate = new DateTimeOffset(theDay.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc), TimeSpan.Zero);
var participants = await questionnaireContext
.Participants.AsNoTracking()
.Where(p => p.NotificationMinutesOffset.HasValue)
.ToListAsync(cancellationToken);
foreach (var participant in participants)
{
if (participant.NotificationMinutesOffset is not int offset)
{
continue;
}
var notificationTime = eventDate.AddMinutes(offset);
if (lastCheck <= notificationTime && notificationTime <= utcNow)
{
await SendNotificationAsync(participant, theDay, cancellationToken);
}
}
return utcNow;
}
private async Task SendNotificationAsync(
Participant participant,
DateOnly theDay,
CancellationToken cancellationToken
)
{
try
{
logger.LogInformation("Sending notification to {PhoneNumber} for scheduled time", participant.PhoneNumber);
var request = new SmsRequest
{
RecipientPhoneNumber = participant.PhoneNumber,
Content = $"Mat den Dagen påminnelse: Dagen är här! {theDay:yyyy-MM-dd}",
};
await smsClient.SendSmsAsync(request, cancellationToken: cancellationToken);
logger.LogDebug(
"Notification sent successfully to {PhoneNumber}: {Message}",
participant.PhoneNumber,
request
);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to send notification to {PhoneNumber}", participant.PhoneNumber);
}
}
}
MatDenDagen/Services/NotificationServiceExtensions.cs +22 -0
diff --git a/MatDenDagen/Services/NotificationServiceExtensions.cs b/MatDenDagen/Services/NotificationServiceExtensions.cs
new file mode 100644
index 0000000..7daceb9
@@ -0,0 +1,22 @@
using System;
using Microsoft.Extensions.DependencyInjection;
namespace MatDenDagen.Services;
public static class NotificationServiceExtensions
{
public static IServiceCollection AddNotificationService(this IServiceCollection services)
{
services.AddOptions<NotificationServiceOptions>().BindConfiguration("NotificationService");
services.AddTransient<NotificationService>();
services.AddHostedService<NotificationBackgroundService>();
return services;
}
}
public sealed class NotificationServiceOptions
{
public TimeSpan CheckInterval { get; set; } = TimeSpan.FromMinutes(5);
}
MatDenDagen/SmsSenderServiceExtensions.cs +34 -0
diff --git a/MatDenDagen/SmsSenderServiceExtensions.cs b/MatDenDagen/SmsSenderServiceExtensions.cs
new file mode 100644
index 0000000..6aa2572
@@ -0,0 +1,34 @@
using System;
using System.ComponentModel.DataAnnotations;
using HuaweiWifiSms.Grpc;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
namespace MatDenDagen;
public static class SmsSenderServiceExtensions
{
public static IServiceCollection AddSmsSenderClient(this IServiceCollection services)
{
services.AddOptions<SmsSenderOptions>().BindConfiguration("SmsSender").ValidateOnStart();
services.AddTransient<IValidateOptions<SmsSenderOptions>, SmsSenderOptionsValidator>();
services
.AddGrpcClient<SmsSender.SmsSenderClient>()
.ConfigureHttpClient(
(sp, client) =>
client.BaseAddress = sp.GetRequiredService<IOptions<SmsSenderOptions>>().Value.BaseAddress
);
return services;
}
}
public sealed class SmsSenderOptions
{
[Required]
public required Uri BaseAddress { get; init; }
}
[OptionsValidator]
public sealed partial class SmsSenderOptionsValidator : IValidateOptions<SmsSenderOptions>;
MatDenDagen/appsettings.Development.json +6 -0
diff --git a/MatDenDagen/appsettings.Development.json b/MatDenDagen/appsettings.Development.json
index a426635..8ec0c8f 100644
@@ -15,5 +15,11 @@
"Admin": {
"Hash": "$2a$12$MZUSzXjWbGcABxprQaviPe3rTNm.ulVGqceDPuKd787jHCnfMAc/C", // admin123
"LoginTime": "08:00:00.000"
},
"SmsSender": {
"BaseAddress": "http://localhost:5000"
},
"NotificationService": {
"CheckInterval": "00:00:05"
}
}
MatDenDagen/packages.lock.json +130 -0
diff --git a/MatDenDagen/packages.lock.json b/MatDenDagen/packages.lock.json
index 52c6159..d26b37d 100644
@@ -14,6 +14,27 @@
"resolved": "1.2.6",
"contentHash": "KMSJG+jfk7vjP52QkWB99qWespXCPAzG/IaMCMRHYWumJEAGKQYm2HtyWG6eqnOwDitH96i1cqq5EVesyOtPmg=="
},
"Google.Protobuf": {
"type": "Direct",
"requested": "[3.34.1, )",
"resolved": "3.34.1",
"contentHash": "212vdYxRuVopGE5bess6Jg5oXWyizA6hcLPTI7G+qA4PthQEvfeof3njT+7VSY5v/+O0P22xTydiP5fSJJpGEA=="
},
"Grpc.Net.ClientFactory": {
"type": "Direct",
"requested": "[2.80.0, )",
"resolved": "2.80.0",
"contentHash": "YtY1DWID2phwiGc8qBG7+wf00Do5jE7BkJgCc6nbu5b50OsD89mSd73oCE8fCnUo7IjtDAEYFOYI3NSzgn28gw==",
"dependencies": {
"Grpc.Net.Client": "2.80.0"
}
},
"Grpc.Tools": {
"type": "Direct",
"requested": "[2.80.0, )",
"resolved": "2.80.0",
"contentHash": "NS1AxwVZnrdbBoMf5L5ruGjBjoLBej9avhqxWNsgfu2GU6FhpxEVJOwLJsdljlDCjvquJiAH2zf/WodIWMvf2w=="
},
"Microsoft.AspNetCore.App.Internal.Assets": {
"type": "Direct",
"requested": "[10.0.7, )",
@@ -49,6 +70,16 @@
"SQLitePCLRaw.core": "2.1.11"
}
},
"Microsoft.Extensions.Http.Resilience": {
"type": "Direct",
"requested": "[10.5.0, )",
"resolved": "10.5.0",
"contentHash": "81rw+wjFFP5jREOERb1PHIPvBNFtE6NXO8bsLTSCET2UZWxj7cwrpzcI3l07tOpHEprYmruZAF3kZEar7uG4Iw==",
"dependencies": {
"Microsoft.Extensions.Http.Diagnostics": "10.5.0",
"Microsoft.Extensions.Resilience": "10.5.0"
}
},
"OpenTelemetry.Exporter.OpenTelemetryProtocol": {
"type": "Direct",
"requested": "[1.15.3, )",
@@ -103,6 +134,27 @@
"OpenTelemetry.Api": "[1.15.3, 2.0.0)"
}
},
"Grpc.Core.Api": {
"type": "Transitive",
"resolved": "2.80.0",
"contentHash": "i/8s+MOrYa6n7BmZ5bilcbHk+EMJDQHm2MKLhwGAT+urQqlZ6cpjvSivYvuULjebuX+UKieLYZbLRCmVQxFRkw=="
},
"Grpc.Net.Client": {
"type": "Transitive",
"resolved": "2.80.0",
"contentHash": "I1Aa24nTRMHqx0pmQfvthFsOpejquDjiJV6092KBqjw6EEr3wA9CMXlrdkoEgHartOiJrpKyZiQRl7n0NVlfBg==",
"dependencies": {
"Grpc.Net.Common": "2.80.0"
}
},
"Grpc.Net.Common": {
"type": "Transitive",
"resolved": "2.80.0",
"contentHash": "E2ERsx+9IXlry4yjBl8btx0XMIKzymGNSvX5jmBS/uSwYyYDoKIDcIREyLqFLvd8vcJdcoRlycpvn9YRXutFpQ==",
"dependencies": {
"Grpc.Core.Api": "2.80.0"
}
},
"Humanizer.Core": {
"type": "Transitive",
"resolved": "2.14.1",
@@ -219,11 +271,68 @@
"SQLitePCLRaw.core": "2.1.11"
}
},
"Microsoft.Extensions.AmbientMetadata.Application": {
"type": "Transitive",
"resolved": "10.5.0",
"contentHash": "lCJjEDknSYeTXB133DwLNwXYA6q9nzJiJFjQb1KO1n3sS6wHfROm6zqG6y3UthQP5oPnNbE1a7M15LpjSf5yBg=="
},
"Microsoft.Extensions.Compliance.Abstractions": {
"type": "Transitive",
"resolved": "10.5.0",
"contentHash": "xbWZji13Vb2jDJNtwVrKpI09jd8x3n3fL+GzhiLK+8O5Wc2A+GyqCZalST2fV46Pf0QfCwkXf83y+3/rDkCd7A=="
},
"Microsoft.Extensions.DependencyInjection.AutoActivation": {
"type": "Transitive",
"resolved": "10.5.0",
"contentHash": "vby/PzPScy9pX3r3f5UuHutxSr4Q8SXqyIiH6+JEK7SVpTCL6f8R9mp04OUVsZLlsME2rBjA9PHXf9L9aG7wbg=="
},
"Microsoft.Extensions.DependencyModel": {
"type": "Transitive",
"resolved": "10.0.7",
"contentHash": "gCglFg/9Chu3lyJNytRuQAYM3mXQKNs1i01Cz2bc545QaHQ+LbBb4O5UCfu968Gro3ZVSOZ/ktilmPcaUSGSZA=="
},
"Microsoft.Extensions.Diagnostics.ExceptionSummarization": {
"type": "Transitive",
"resolved": "10.5.0",
"contentHash": "+jdC9YUfMkX9/Yb3Pi8Kovt1nFVGGB2UqSHZgLapo63d+WAhYf9KiuNA3jiaaRINhVyCgWuKFoMtjWKET5oXEQ=="
},
"Microsoft.Extensions.Http.Diagnostics": {
"type": "Transitive",
"resolved": "10.5.0",
"contentHash": "HoWdJKvBt7vkLlclRbjDTXcCp3s9hwFf1CY4ovlmMKFAbKSI7zKl0fUQ4LMvUI3sHIhpEtMjp7Mxjaf/yEmVvQ==",
"dependencies": {
"Microsoft.Extensions.Telemetry": "10.5.0"
}
},
"Microsoft.Extensions.Resilience": {
"type": "Transitive",
"resolved": "10.5.0",
"contentHash": "yjbGQkSqLkP8/lKZLfaUcdkNUpWUqMafCsm56kw9uzznhJb/uJiIRy5/zG9D0SFsBzJkz2AcvWU2J/MJydPxoA==",
"dependencies": {
"Microsoft.Extensions.Diagnostics.ExceptionSummarization": "10.5.0",
"Microsoft.Extensions.Telemetry.Abstractions": "10.5.0",
"Polly.Extensions": "8.4.2",
"Polly.RateLimiting": "8.4.2"
}
},
"Microsoft.Extensions.Telemetry": {
"type": "Transitive",
"resolved": "10.5.0",
"contentHash": "jI7b9rkfoz06ZEQols6WG3D0iQMIbtRDHkx1F7QvQOSDmzyXLwUIBbJEO8ftr7aD/2tvsHplqycp+WXFvMfujg==",
"dependencies": {
"Microsoft.Extensions.AmbientMetadata.Application": "10.5.0",
"Microsoft.Extensions.DependencyInjection.AutoActivation": "10.5.0",
"Microsoft.Extensions.Telemetry.Abstractions": "10.5.0"
}
},
"Microsoft.Extensions.Telemetry.Abstractions": {
"type": "Transitive",
"resolved": "10.5.0",
"contentHash": "VmU7e6xHqoubWKl7y9MtWyQAjlDpvbds3gY8ZKMS/1GxY2+U1/aMNnMj09aOXAa3p5qhHSSkBzDJvyokCjVkPg==",
"dependencies": {
"Microsoft.Extensions.Compliance.Abstractions": "10.5.0"
}
},
"Microsoft.VisualStudio.SolutionPersistence": {
"type": "Transitive",
"resolved": "1.0.52",
@@ -263,6 +372,27 @@
"OpenTelemetry.Api": "1.15.3"
}
},
"Polly.Core": {
"type": "Transitive",
"resolved": "8.4.2",
"contentHash": "BpE2I6HBYYA5tF0Vn4eoQOGYTYIK1BlF5EXVgkWGn3mqUUjbXAr13J6fZVbp7Q3epRR8yshacBMlsHMhpOiV3g=="
},
"Polly.Extensions": {
"type": "Transitive",
"resolved": "8.4.2",
"contentHash": "GZ9vRVmR0jV2JtZavt+pGUsQ1O1cuRKG7R7VOZI6ZDy9y6RNPvRvXK1tuS4ffUrv8L0FTea59oEuQzgS0R7zSA==",
"dependencies": {
"Polly.Core": "8.4.2"
}
},
"Polly.RateLimiting": {
"type": "Transitive",
"resolved": "8.4.2",
"contentHash": "ehTImQ/eUyO07VYW2WvwSmU9rRH200SKJ/3jku9rOkyWE0A2JxNFmAVms8dSn49QLSjmjFRRSgfNyOgr/2PSmA==",
"dependencies": {
"Polly.Core": "8.4.2"
}
},
"SQLitePCLRaw.bundle_e_sqlite3": {
"type": "Transitive",
"resolved": "2.1.11",
README.md +3 -3
diff --git a/README.md b/README.md
index 5fbe95e..84a7827 100644
@@ -7,7 +7,7 @@
- [x] Hide the date by default
- [x] Submit questionnaire
- [x] Questionnaire only accepts phone numbers from configured participants
- [ ] Admin pages
- [x] Admin pages
- [x] Password protection
- [x] Configure possible date span for **the day**
- [x] (Re)roll date of **the day**
@@ -16,7 +16,7 @@
- [x] Time of day the want to be notified
- [x] Configure questions
- [x] Export answers
- [ ] Internals
- [ ] Job that sends out notifications
- [x] Internals
- [x] Job that sends out notifications
- [x] Storage for text answers
- [x] Storage for photo (video?) uploads