From 170ef9883ae1bcab540f715ab2b857c587cef92d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Schmidt?= <9435005+AnSch1510@users.noreply.github.com> Date: Tue, 2 Jul 2024 21:13:49 +0200 Subject: [PATCH 01/12] add template for send push button --- .../user-settings/user-settings.component.html | 16 ++++++++++++++++ .../user-settings/user-settings.component.ts | 12 +++++++++++- src/client/src/app/i18n/de.json | 4 ++++ src/client/src/app/i18n/en.json | 4 ++++ 4 files changed, 35 insertions(+), 1 deletion(-) diff --git a/src/client/src/app/components/user-settings/user-settings.component.html b/src/client/src/app/components/user-settings/user-settings.component.html index 0467971..edff1ff 100644 --- a/src/client/src/app/components/user-settings/user-settings.component.html +++ b/src/client/src/app/components/user-settings/user-settings.component.html @@ -102,6 +102,22 @@

{{ translations.nav_settings() }}

@if (settings.enableNotifications && notificationsEnabled()) { +
+
+ {{ translations.settings_notifications_testNotification_text() }} +
+
+ + +
+

{{ translations.settings_notifications_notify_title() }}

diff --git a/src/client/src/app/components/user-settings/user-settings.component.ts b/src/client/src/app/components/user-settings/user-settings.component.ts index 7e8930c..14d1f16 100644 --- a/src/client/src/app/components/user-settings/user-settings.component.ts +++ b/src/client/src/app/components/user-settings/user-settings.component.ts @@ -12,7 +12,7 @@ import { FloatLabelModule } from 'primeng/floatlabel'; import { InputSwitchModule } from 'primeng/inputswitch'; import { MessagesModule } from 'primeng/messages'; import { ProgressSpinnerModule } from 'primeng/progressspinner'; -import { filter, map, Subject } from 'rxjs'; +import { filter, map, Subject, timer } from 'rxjs'; import { SavedFadingMessageComponent } from '../+common/saved-fading-message.component'; import { isActionBusy, hasActionFailed } from '../../+state/action-state'; @@ -132,6 +132,7 @@ export class UserSettingsComponent { ); protected readonly isUpdatingPushSubscription = signal(false); + protected readonly sendPush = signal(false); protected readonly isLoading = computed(() => isActionBusy(this._loadActionState())); protected readonly isUpdating = computed(() => isActionBusy(this._updateActionState())); protected readonly hasFailed = computed(() => hasActionFailed(this._loadActionState())); @@ -142,6 +143,7 @@ export class UserSettingsComponent { notifyEventStart: this.getSaveState('notifyOnEventStart'), notifyEventUpdate: this.getSaveState('notifyOnEventUpdated'), notifyTimeslotStart: this.getSaveState('notifyOnTimeslotStart'), + testSend: toObservable(this.sendPush), }; constructor() { @@ -166,6 +168,14 @@ export class UserSettingsComponent { this._store.dispatch(updateUserSettingsAction(changes)); } + protected async sendTestNotification() { + console.log('test'); + this.sendPush.set(true); + timer(1000).subscribe(() => { + this.sendPush.set(false); + }); + } + protected async toggleNotifications(enabled: boolean) { if (!this.notificationsPossible) return; this.isUpdatingPushSubscription.set(true); diff --git a/src/client/src/app/i18n/de.json b/src/client/src/app/i18n/de.json index 2685084..8c45720 100644 --- a/src/client/src/app/i18n/de.json +++ b/src/client/src/app/i18n/de.json @@ -41,6 +41,10 @@ "title": "Benachrichtigungen", "enabledOnAllDevices": "Auf allen Geräten aktiviert", "eanbled": "Auf diesem Gerät aktiviert", + "testNotification": { + "text": "Testbenachrichtigung", + "buttonText": "senden" + }, "errors": { "notSupported": "Benachrichtigungen werden von diesem Browser nicht unterstützt. Gegenebenfalls muss die Webseite auf dem Home-Bildschirm installiert werden.", "notGranted": { diff --git a/src/client/src/app/i18n/en.json b/src/client/src/app/i18n/en.json index 1b3c642..e4ffd98 100644 --- a/src/client/src/app/i18n/en.json +++ b/src/client/src/app/i18n/en.json @@ -41,6 +41,10 @@ "title": "Notifications", "enabledOnAllDevices": "Enabled on all devices", "eanbled": "Enabled on this device", + "testNotification": { + "text": "test notification", + "buttonText": "send" + }, "errors": { "notSupported": "Notifications are not supported by this browser. Probably this website needs to be installed onto the homescreen.", "notGranted": { From f762a05b79a6b4cf58a420b237d3ab0602f4531e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Schmidt?= <9435005+AnSch1510@users.noreply.github.com> Date: Tue, 2 Jul 2024 22:45:10 +0200 Subject: [PATCH 02/12] server working --- .../Models/Push/PushNotificationData.cs | 12 ++ .../Notifications/SendNotificationEndpoint.cs | 140 ++++++++++++++++++ 2 files changed, 152 insertions(+) create mode 100644 src/server/host/Endpoints/Notifications/SendNotificationEndpoint.cs diff --git a/src/server/domain/Models/Push/PushNotificationData.cs b/src/server/domain/Models/Push/PushNotificationData.cs index 0cd9ad2..2cae2cf 100644 --- a/src/server/domain/Models/Push/PushNotificationData.cs +++ b/src/server/domain/Models/Push/PushNotificationData.cs @@ -195,6 +195,18 @@ public string GetBody(string lang) => }; } + public record TestNotification(string Title, string Body) : IPushNotificationData + { + public string Type => "test-notification"; + + public Dictionary OnActionClick => + new() { { "default", new($"/events") } }; + + public string GetTitle(string lang) => Title; + + public string GetBody(string lang) => Body; + } + private static string NormalizeLang(string lang) { if (lang.StartsWith("de", StringComparison.OrdinalIgnoreCase)) diff --git a/src/server/host/Endpoints/Notifications/SendNotificationEndpoint.cs b/src/server/host/Endpoints/Notifications/SendNotificationEndpoint.cs new file mode 100644 index 0000000..2580da9 --- /dev/null +++ b/src/server/host/Endpoints/Notifications/SendNotificationEndpoint.cs @@ -0,0 +1,140 @@ +using System.ComponentModel.DataAnnotations; +using FastEndpoints; +using FluentValidation; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure.Internal; +using Microsoft.IdentityModel.Tokens; +using MinigolfFriday.Data; +using MinigolfFriday.Data.Entities; +using MinigolfFriday.Domain.Models; +using MinigolfFriday.Domain.Models.Push; +using MinigolfFriday.Domain.Models.RealtimeEvents; +using MinigolfFriday.Host.Common; +using MinigolfFriday.Host.Mappers; +using MinigolfFriday.Host.Services; +using MinigolfFriday.Host.Utilities; + +namespace MinigolfFriday.Host.Endpoints.Notifications; + +public record SendNotificationRequest(string? UserId, string? Title, string? Body); + +public class SendNotificationRequestValidator : Validator +{ + public SendNotificationRequestValidator(IIdService idService) + { + // RuleFor(x => x.UserId).NotNull().ValidSqid(idService.User); + } +} + +public class SendNotificationEndpoint( + DatabaseContext databaseContext, + IJwtService jwtService, + IIdService idService, + IUserPushSubscriptionMapper userPushSubscriptionMapper, + IWebPushService webPushService +) : Endpoint +{ + public override void Configure() + { + Post(""); + Group(); + this.ProducesErrors(EndpointErrors.UserNotFound, EndpointErrors.UserIdNotInClaims); + } + + public override async Task HandleAsync(SendNotificationRequest req, CancellationToken ct) + { + if (!jwtService.TryGetUserId(User, out var userId)) + { + Logger.LogWarning(EndpointErrors.UserIdNotInClaims); + await this.SendErrorAsync(EndpointErrors.UserIdNotInClaims, ct); + return; + } + if (!req.UserId.IsNullOrEmpty()) + { + // TODO: check if we are admin - if not, this must not be possible + userId = idService.User.DecodeSingle(req.UserId); + } + + var user = null as UserEntity; + try + { + user = await databaseContext + .Users.Include(x => x.Settings) + .FirstAsync(x => x.Id == userId, ct); + } + catch (System.Exception) + { + Logger.LogWarning(EndpointErrors.UserNotFound, req.UserId); + await this.SendErrorAsync(EndpointErrors.UserNotFound, req.UserId, ct); + return; + } + + Logger.LogWarning("send to userId: {} ", user.Alias); + + var notifications = await databaseContext + .Users.Where(u => u.Id == userId) + .Select(u => new + { + Subscriptions = u.PushSubscriptions.Select(x => new UserPushSubscription( + x.Id, + x.UserId, + x.Lang, + x.Endpoint, + x.P256DH, + x.Auth + )), + NotificationData = new PushNotificationData.TestNotification( + req.Title.IsNullOrEmpty() ? "Body" : req.Title, + req.Body.IsNullOrEmpty() ? "Test" : req.Body + ) + }) + .ToArrayAsync(ct); + foreach (var notification in notifications) + await webPushService.SendAsync( + notification.Subscriptions, + notification.NotificationData, + ct + ); + + // var user = await databaseContext + // .Users.Include(x => x.Settings) + // .FirstAsync(x => x.Id == userId, ct); + + // if (user.Settings == null) + // { + // user.Settings = new() + // { + // EnableNotifications = req.EnableNotifications ?? true, + // NotifyOnEventPublish = req.NotifyOnEventPublish ?? true, + // NotifyOnEventStart = req.NotifyOnEventStart ?? true, + // NotifyOnEventUpdated = req.NotifyOnEventUpdated ?? true, + // NotifyOnTimeslotStart = req.NotifyOnTimeslotStart ?? true, + // SecondsToNotifyBeforeTimeslotStart = req.SecondsToNotifyBeforeTimeslotStart ?? 600 + // }; + // } + // else + // { + // user.Settings.EnableNotifications = + // req.EnableNotifications ?? user.Settings.EnableNotifications; + // user.Settings.NotifyOnEventPublish = + // req.NotifyOnEventPublish ?? user.Settings.NotifyOnEventPublish; + // user.Settings.NotifyOnEventStart = + // req.NotifyOnEventStart ?? user.Settings.NotifyOnEventStart; + // user.Settings.NotifyOnEventUpdated = + // req.NotifyOnEventUpdated ?? user.Settings.NotifyOnEventUpdated; + // user.Settings.NotifyOnTimeslotStart = + // req.NotifyOnTimeslotStart ?? user.Settings.NotifyOnTimeslotStart; + // user.Settings.SecondsToNotifyBeforeTimeslotStart = + // req.SecondsToNotifyBeforeTimeslotStart + // ?? user.Settings.SecondsToNotifyBeforeTimeslotStart; + // } + + // await databaseContext.SaveChangesAsync(ct); + await SendOkAsync(ct); + + // await realtimeEventsService.SendEventAsync( + // new RealtimeEvent.UserSettingsChanged(idService.User.Encode(userId)), + // ct + // ); + } +} From 1820a18065fff19382b03e4e04fffd9a419b6590 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Schmidt?= <9435005+AnSch1510@users.noreply.github.com> Date: Wed, 3 Jul 2024 19:45:45 +0200 Subject: [PATCH 03/12] send notification request to server --- .../user-settings/user-settings.component.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src/client/src/app/components/user-settings/user-settings.component.ts b/src/client/src/app/components/user-settings/user-settings.component.ts index 14d1f16..28ab844 100644 --- a/src/client/src/app/components/user-settings/user-settings.component.ts +++ b/src/client/src/app/components/user-settings/user-settings.component.ts @@ -12,7 +12,7 @@ import { FloatLabelModule } from 'primeng/floatlabel'; import { InputSwitchModule } from 'primeng/inputswitch'; import { MessagesModule } from 'primeng/messages'; import { ProgressSpinnerModule } from 'primeng/progressspinner'; -import { filter, map, Subject, timer } from 'rxjs'; +import { filter, map, Subject } from 'rxjs'; import { SavedFadingMessageComponent } from '../+common/saved-fading-message.component'; import { isActionBusy, hasActionFailed } from '../../+state/action-state'; @@ -22,6 +22,7 @@ import { updateUserSettingsAction, } from '../../+state/user-settings'; import { keepUserSettingsLoaded } from '../../+state/user-settings/user-settings.utils'; +import { NotificationsService } from '../../api/services'; import { ResetNgModelDirective } from '../../directives/reset-ng-model.directive'; import { UserSettings } from '../../models/parsed-models'; import { AuthService } from '../../services/auth.service'; @@ -75,6 +76,7 @@ export class UserSettingsComponent { private readonly _actions$ = inject(Actions); private readonly _messageService = inject(MessageService); private readonly _webPushService = inject(WebPushService); + private readonly _notificationService = inject(NotificationsService); private readonly _loadActionState = selectSignal(selectUserSettingsActionState('load')); private readonly _updateActionState = selectSignal(selectUserSettingsActionState('update')); @@ -169,11 +171,11 @@ export class UserSettingsComponent { } protected async sendTestNotification() { - console.log('test'); - this.sendPush.set(true); - timer(1000).subscribe(() => { - this.sendPush.set(false); - }); + this.sendPush.set(false); + const response = await this._notificationService.sendNotification({ body: {} }); + if (response.ok) { + this.sendPush.set(true); + } } protected async toggleNotifications(enabled: boolean) { From a91de6784bff7a8a83ed89d4de5c794fa2454b80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Schmidt?= <9435005+AnSch1510@users.noreply.github.com> Date: Wed, 3 Jul 2024 20:00:35 +0200 Subject: [PATCH 04/12] translate send notification success bunner --- .../components/+common/saved-fading-message.component.ts | 8 ++++++-- .../components/user-settings/user-settings.component.html | 4 +++- src/client/src/app/i18n/de.json | 3 ++- src/client/src/app/i18n/en.json | 5 +++-- 4 files changed, 14 insertions(+), 6 deletions(-) diff --git a/src/client/src/app/components/+common/saved-fading-message.component.ts b/src/client/src/app/components/+common/saved-fading-message.component.ts index 1d1de76..a8cded5 100644 --- a/src/client/src/app/components/+common/saved-fading-message.component.ts +++ b/src/client/src/app/components/+common/saved-fading-message.component.ts @@ -12,8 +12,12 @@ import { TranslateService } from '../../services/translate.service'; class="pointer-events-none absolute top-1/2 -translate-x-[calc(100%+8px)] -translate-y-1/2 truncate rounded bg-green-100 px-4 py-2 text-green-900 dark:bg-green-900 dark:text-green-200" [showTrigger]="showTrigger()" > - - {{ translations.shared_saved() }} +
+ + + {{ translations.shared_saved() }} + +
`, changeDetection: ChangeDetectionStrategy.OnPush, diff --git a/src/client/src/app/components/user-settings/user-settings.component.html b/src/client/src/app/components/user-settings/user-settings.component.html index edff1ff..0398f23 100644 --- a/src/client/src/app/components/user-settings/user-settings.component.html +++ b/src/client/src/app/components/user-settings/user-settings.component.html @@ -115,7 +115,9 @@

{{ translations.nav_settings() }}

[severity]="'primary'" (onClick)="sendTestNotification()" /> - + {{ + translations.settings_notifications_testNotification_successText() + }}

{{ translations.settings_notifications_notify_title() }}

diff --git a/src/client/src/app/i18n/de.json b/src/client/src/app/i18n/de.json index 8c45720..6f4955b 100644 --- a/src/client/src/app/i18n/de.json +++ b/src/client/src/app/i18n/de.json @@ -43,7 +43,8 @@ "eanbled": "Auf diesem Gerät aktiviert", "testNotification": { "text": "Testbenachrichtigung", - "buttonText": "senden" + "buttonText": "Senden", + "successText": "Gesendet" }, "errors": { "notSupported": "Benachrichtigungen werden von diesem Browser nicht unterstützt. Gegenebenfalls muss die Webseite auf dem Home-Bildschirm installiert werden.", diff --git a/src/client/src/app/i18n/en.json b/src/client/src/app/i18n/en.json index e4ffd98..df3645b 100644 --- a/src/client/src/app/i18n/en.json +++ b/src/client/src/app/i18n/en.json @@ -42,8 +42,9 @@ "enabledOnAllDevices": "Enabled on all devices", "eanbled": "Enabled on this device", "testNotification": { - "text": "test notification", - "buttonText": "send" + "text": "Test notification", + "buttonText": "Send", + "successText": "Sent" }, "errors": { "notSupported": "Notifications are not supported by this browser. Probably this website needs to be installed onto the homescreen.", From d4aa55a4505b9a389a9cc52ab960bba26183e565 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Schmidt?= <9435005+AnSch1510@users.noreply.github.com> Date: Wed, 3 Jul 2024 20:04:55 +0200 Subject: [PATCH 05/12] don't animate update button --- src/client/src/app/components/app/menu/menu.component.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/client/src/app/components/app/menu/menu.component.html b/src/client/src/app/components/app/menu/menu.component.html index 001f0d5..3cccfe4 100644 --- a/src/client/src/app/components/app/menu/menu.component.html +++ b/src/client/src/app/components/app/menu/menu.component.html @@ -67,7 +67,7 @@ @if (newVersionAvailable()) {
-
+
Date: Wed, 3 Jul 2024 20:15:51 +0200 Subject: [PATCH 06/12] check if user has admin role to be able to send notifications to other users --- .../Notifications/SendNotificationEndpoint.cs | 49 ++----------------- src/server/host/Services/JwtService.cs | 5 ++ 2 files changed, 10 insertions(+), 44 deletions(-) diff --git a/src/server/host/Endpoints/Notifications/SendNotificationEndpoint.cs b/src/server/host/Endpoints/Notifications/SendNotificationEndpoint.cs index 2580da9..c0afef7 100644 --- a/src/server/host/Endpoints/Notifications/SendNotificationEndpoint.cs +++ b/src/server/host/Endpoints/Notifications/SendNotificationEndpoint.cs @@ -30,7 +30,6 @@ public class SendNotificationEndpoint( DatabaseContext databaseContext, IJwtService jwtService, IIdService idService, - IUserPushSubscriptionMapper userPushSubscriptionMapper, IWebPushService webPushService ) : Endpoint { @@ -49,9 +48,8 @@ public override async Task HandleAsync(SendNotificationRequest req, Cancellation await this.SendErrorAsync(EndpointErrors.UserIdNotInClaims, ct); return; } - if (!req.UserId.IsNullOrEmpty()) + if (!req.UserId.IsNullOrEmpty() && jwtService.HasRole(User, Role.Admin)) { - // TODO: check if we are admin - if not, this must not be possible userId = idService.User.DecodeSingle(req.UserId); } @@ -84,8 +82,10 @@ public override async Task HandleAsync(SendNotificationRequest req, Cancellation x.Auth )), NotificationData = new PushNotificationData.TestNotification( - req.Title.IsNullOrEmpty() ? "Body" : req.Title, - req.Body.IsNullOrEmpty() ? "Test" : req.Body + string.IsNullOrEmpty(req.Title) + ? "Dies ist eine Testbenachrichtigung" + : req.Title, + string.IsNullOrEmpty(req.Body) ? "Testbenachrichtigung" : req.Body ) }) .ToArrayAsync(ct); @@ -96,45 +96,6 @@ await webPushService.SendAsync( ct ); - // var user = await databaseContext - // .Users.Include(x => x.Settings) - // .FirstAsync(x => x.Id == userId, ct); - - // if (user.Settings == null) - // { - // user.Settings = new() - // { - // EnableNotifications = req.EnableNotifications ?? true, - // NotifyOnEventPublish = req.NotifyOnEventPublish ?? true, - // NotifyOnEventStart = req.NotifyOnEventStart ?? true, - // NotifyOnEventUpdated = req.NotifyOnEventUpdated ?? true, - // NotifyOnTimeslotStart = req.NotifyOnTimeslotStart ?? true, - // SecondsToNotifyBeforeTimeslotStart = req.SecondsToNotifyBeforeTimeslotStart ?? 600 - // }; - // } - // else - // { - // user.Settings.EnableNotifications = - // req.EnableNotifications ?? user.Settings.EnableNotifications; - // user.Settings.NotifyOnEventPublish = - // req.NotifyOnEventPublish ?? user.Settings.NotifyOnEventPublish; - // user.Settings.NotifyOnEventStart = - // req.NotifyOnEventStart ?? user.Settings.NotifyOnEventStart; - // user.Settings.NotifyOnEventUpdated = - // req.NotifyOnEventUpdated ?? user.Settings.NotifyOnEventUpdated; - // user.Settings.NotifyOnTimeslotStart = - // req.NotifyOnTimeslotStart ?? user.Settings.NotifyOnTimeslotStart; - // user.Settings.SecondsToNotifyBeforeTimeslotStart = - // req.SecondsToNotifyBeforeTimeslotStart - // ?? user.Settings.SecondsToNotifyBeforeTimeslotStart; - // } - - // await databaseContext.SaveChangesAsync(ct); await SendOkAsync(ct); - - // await realtimeEventsService.SendEventAsync( - // new RealtimeEvent.UserSettingsChanged(idService.User.Encode(userId)), - // ct - // ); } } diff --git a/src/server/host/Services/JwtService.cs b/src/server/host/Services/JwtService.cs index dd45525..4e12d50 100644 --- a/src/server/host/Services/JwtService.cs +++ b/src/server/host/Services/JwtService.cs @@ -70,4 +70,9 @@ public bool TryGetUserId(ClaimsPrincipal user, out long userId) userId = -1; return false; } + + public bool HasRole(ClaimsPrincipal user, Role role) + { + return user.IsInRole(FastEnum.GetName(role)!); + } } From 437a1bc7a3ff6f979d39611c2cf201114afa678e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Schmidt?= <9435005+AnSch1510@users.noreply.github.com> Date: Wed, 3 Jul 2024 20:32:37 +0200 Subject: [PATCH 07/12] include 'HasPushSubscription' in User record --- src/server/domain/Models/User.cs | 4 ++- .../Users/CreateUserEndpoint.cs | 8 +++++- .../host/Endpoints/Auth/GetTokenEndpoint.cs | 3 ++- .../SubscribeToNotificationsEndpoint.cs | 12 ++++++++- .../UnsubscribeFromNotificationsEndpoint.cs | 25 +++++++++++++++++-- src/server/host/Mappers/UserMapper.cs | 3 ++- src/server/host/Services/WebPushService.cs | 12 ++++++++- 7 files changed, 59 insertions(+), 8 deletions(-) diff --git a/src/server/domain/Models/User.cs b/src/server/domain/Models/User.cs index 122201c..7e098d6 100644 --- a/src/server/domain/Models/User.cs +++ b/src/server/domain/Models/User.cs @@ -9,11 +9,13 @@ namespace MinigolfFriday.Domain.Models; /// The alias that is used to display the user in the UI. /// The assigned roles to the user. /// Preferences regarding other players. +/// Determines wether the user has active push subscriptions. public record User( [property: Required] string Id, [property: Required] string Alias, [property: Required] Role[] Roles, - [property: Required] PlayerPreferences PlayerPreferences + [property: Required] PlayerPreferences PlayerPreferences, + bool? HasPushSubscription ); /// diff --git a/src/server/host/Endpoints/Administration/Users/CreateUserEndpoint.cs b/src/server/host/Endpoints/Administration/Users/CreateUserEndpoint.cs index 544a81f..9f84f7b 100644 --- a/src/server/host/Endpoints/Administration/Users/CreateUserEndpoint.cs +++ b/src/server/host/Endpoints/Administration/Users/CreateUserEndpoint.cs @@ -83,7 +83,13 @@ public override async Task HandleAsync(CreateUserRequest req, CancellationToken await databaseContext.SaveChangesAsync(ct); await SendAsync( new( - new(idService.User.Encode(user.Id), req.Alias, req.Roles, req.PlayerPreferences), + new( + idService.User.Encode(user.Id), + req.Alias, + req.Roles, + req.PlayerPreferences, + false + ), user.LoginToken ), 201, diff --git a/src/server/host/Endpoints/Auth/GetTokenEndpoint.cs b/src/server/host/Endpoints/Auth/GetTokenEndpoint.cs index 228cef2..3799bc9 100644 --- a/src/server/host/Endpoints/Auth/GetTokenEndpoint.cs +++ b/src/server/host/Endpoints/Auth/GetTokenEndpoint.cs @@ -73,7 +73,8 @@ public override async Task HandleAsync(GetTokenRequest req, CancellationToken ct user.Id < 0 ? "admin" : idService.User.Encode(user.Id), user.Alias ?? "unknown", user.Roles.ToArray(), - new([], []) + new([], []), + null ); await SendAsync(new(token.ToTokenString(), token.ValidTo, u), cancellation: ct); } diff --git a/src/server/host/Endpoints/Notifications/SubscribeToNotificationsEndpoint.cs b/src/server/host/Endpoints/Notifications/SubscribeToNotificationsEndpoint.cs index d40f1ac..ad502a0 100644 --- a/src/server/host/Endpoints/Notifications/SubscribeToNotificationsEndpoint.cs +++ b/src/server/host/Endpoints/Notifications/SubscribeToNotificationsEndpoint.cs @@ -3,6 +3,7 @@ using MaSch.Core.Extensions; using Microsoft.EntityFrameworkCore; using MinigolfFriday.Data; +using MinigolfFriday.Domain.Models.RealtimeEvents; using MinigolfFriday.Host.Common; using MinigolfFriday.Host.Services; @@ -31,7 +32,9 @@ public SubscribeToNotificationsRequestValidator() /// Subscribe to notifications. public class SubscribeToNotificationsEndpoint( DatabaseContext databaseContext, - IJwtService jwtService + IJwtService jwtService, + IRealtimeEventsService realtimeEventsService, + IIdService idService ) : Endpoint { public override void Configure() @@ -89,5 +92,12 @@ CancellationToken ct } await SendOkAsync(ct); + await realtimeEventsService.SendEventAsync( + new RealtimeEvent.UserChanged( + idService.User.Encode(userId), + RealtimeEventChangeType.Updated + ), + ct + ); } } diff --git a/src/server/host/Endpoints/Notifications/UnsubscribeFromNotificationsEndpoint.cs b/src/server/host/Endpoints/Notifications/UnsubscribeFromNotificationsEndpoint.cs index 3bc14c2..ee1a907 100644 --- a/src/server/host/Endpoints/Notifications/UnsubscribeFromNotificationsEndpoint.cs +++ b/src/server/host/Endpoints/Notifications/UnsubscribeFromNotificationsEndpoint.cs @@ -2,6 +2,9 @@ using FluentValidation; using Microsoft.EntityFrameworkCore; using MinigolfFriday.Data; +using MinigolfFriday.Domain.Models.RealtimeEvents; +using MinigolfFriday.Host.Common; +using MinigolfFriday.Host.Services; namespace MinigolfFriday.Host.Endpoints.Notifications; @@ -18,8 +21,12 @@ public UnsubscribeFromNotificationsRequestValidator() } /// Unsubscribe from notifications. -public class UnsubscribeFromNotificationsEndpoint(DatabaseContext databaseContext) - : Endpoint +public class UnsubscribeFromNotificationsEndpoint( + DatabaseContext databaseContext, + IJwtService jwtService, + IRealtimeEventsService realtimeEventsService, + IIdService idService +) : Endpoint { public override void Configure() { @@ -28,6 +35,7 @@ public override void Configure() Description(x => x.ClearDefaultAccepts().Accepts("application/json") ); + this.ProducesErrors(EndpointErrors.UserIdNotInClaims); } public override async Task HandleAsync( @@ -35,9 +43,22 @@ public override async Task HandleAsync( CancellationToken ct ) { + if (!jwtService.TryGetUserId(User, out var userId)) + { + Logger.LogWarning(EndpointErrors.UserIdNotInClaims); + await this.SendErrorAsync(EndpointErrors.UserIdNotInClaims, ct); + return; + } await databaseContext .UserPushSubscriptions.Where(x => x.Endpoint == req.Endpoint) .ExecuteDeleteAsync(ct); await SendOkAsync(ct); + await realtimeEventsService.SendEventAsync( + new RealtimeEvent.UserChanged( + idService.User.Encode(userId), + RealtimeEventChangeType.Updated + ), + ct + ); } } diff --git a/src/server/host/Mappers/UserMapper.cs b/src/server/host/Mappers/UserMapper.cs index 301b196..d2ca87d 100644 --- a/src/server/host/Mappers/UserMapper.cs +++ b/src/server/host/Mappers/UserMapper.cs @@ -18,7 +18,8 @@ public class UserMapper(IIdService idService) : IUserMapper new( entity.Avoid.Select(x => idService.User.Encode(x.Id)).ToArray(), entity.Prefer.Select(x => idService.User.Encode(x.Id)).ToArray() - ) + ), + entity.PushSubscriptions.Any() ); public User Map(UserEntity entity) => MapUserExpression.Compile()(entity); diff --git a/src/server/host/Services/WebPushService.cs b/src/server/host/Services/WebPushService.cs index 7a5fd4b..943a354 100644 --- a/src/server/host/Services/WebPushService.cs +++ b/src/server/host/Services/WebPushService.cs @@ -6,6 +6,7 @@ using MinigolfFriday.Data; using MinigolfFriday.Domain.Models; using MinigolfFriday.Domain.Models.Push; +using MinigolfFriday.Domain.Models.RealtimeEvents; using MinigolfFriday.Domain.Options; using WebPush; @@ -17,7 +18,9 @@ public sealed class WebPushService( IOptions webPushOptions, IConfigureOptions configureJsonSerializerOptions, DatabaseContext databaseContext, - ILogger logger + ILogger logger, + IRealtimeEventsService realtimeEventsService, + IIdService idService ) : IWebPushService, IDisposable { private readonly JsonSerializerOptions _serializerOptions = GetJsonSerializerOptions( @@ -185,6 +188,13 @@ CancellationToken cancellation await databaseContext .UserPushSubscriptions.Where(x => x.Id == subscription.Id) .ExecuteDeleteAsync(cancellation); + await realtimeEventsService.SendEventAsync( + new RealtimeEvent.UserChanged( + idService.User.Encode(subscription.UserId), + RealtimeEventChangeType.Updated + ), + cancellation + ); } private Task RetrySendAsync( From 339654457d6a9b81538466db7764232bdb4bb1a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Schmidt?= <9435005+AnSch1510@users.noreply.github.com> Date: Wed, 3 Jul 2024 21:04:17 +0200 Subject: [PATCH 08/12] indicate on users component, if a user has push subscriptions enabled --- src/client/src/app/components/users/users.component.html | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/client/src/app/components/users/users.component.html b/src/client/src/app/components/users/users.component.html index 2c440a8..eb08bf3 100644 --- a/src/client/src/app/components/users/users.component.html +++ b/src/client/src/app/components/users/users.component.html @@ -37,6 +37,9 @@ [class.border-t-[1px]]="index > 0" > + @if (user.hasPushSubscription) { + + } Date: Wed, 3 Jul 2024 21:04:53 +0200 Subject: [PATCH 09/12] fix unsubscribe on logout --- src/client/src/app/services/auth.service.ts | 28 +++++++++++-------- .../src/app/services/web-push.service.ts | 8 +++--- 2 files changed, 20 insertions(+), 16 deletions(-) diff --git a/src/client/src/app/services/auth.service.ts b/src/client/src/app/services/auth.service.ts index 3809be5..7566efd 100644 --- a/src/client/src/app/services/auth.service.ts +++ b/src/client/src/app/services/auth.service.ts @@ -1,15 +1,7 @@ -import { - EventEmitter, - Injectable, - OnDestroy, - computed, - effect, - inject, - signal, -} from '@angular/core'; +import { Injectable, OnDestroy, computed, effect, inject, signal } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { Router } from '@angular/router'; -import { filter } from 'rxjs'; +import { filter, Unsubscribable } from 'rxjs'; import { AuthTokenInfo, @@ -30,6 +22,7 @@ export class AuthService implements OnDestroy { private readonly _api = inject(AuthenticationService); private readonly _router = inject(Router); + private readonly _beforeSignOut: (() => Promise)[] = []; private readonly _token = signal(undefined); private _tokenRefreshTimeout?: any; @@ -37,7 +30,6 @@ export class AuthService implements OnDestroy { public readonly token = this._token.asReadonly(); public readonly user = computed(() => this.token()?.user); public readonly isAuthorized = computed(() => !!this._token()); - public readonly onBeforeSignOut = new EventEmitter(); constructor() { effect(() => { @@ -130,13 +122,25 @@ export class AuthService implements OnDestroy { public async signOut() { if (!environment.authenticationRequired) return; - this.onBeforeSignOut.emit(); + await Promise.all(this._beforeSignOut.map(x => x())); setLoginToken(null); this._token.set(null); this._router.navigate(['/login']); } + public onBeforeSignOut(action: () => Promise): Unsubscribable { + this._beforeSignOut.push(action); + return { + unsubscribe: () => { + const index = this._beforeSignOut.indexOf(action); + if (index >= 0) { + this._beforeSignOut.splice(index, 1); + } + }, + }; + } + public ngOnDestroy(): void { this.clearTokenRefreshTimeout(); } diff --git a/src/client/src/app/services/web-push.service.ts b/src/client/src/app/services/web-push.service.ts index 467af07..d1e2ea3 100644 --- a/src/client/src/app/services/web-push.service.ts +++ b/src/client/src/app/services/web-push.service.ts @@ -1,4 +1,4 @@ -import { computed, EventEmitter, inject, Injectable, signal } from '@angular/core'; +import { computed, DestroyRef, EventEmitter, inject, Injectable, signal } from '@angular/core'; import { toObservable, takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop'; import { SwPush } from '@angular/service-worker'; import { combineLatest, filter, first, startWith, pairwise, firstValueFrom, map } from 'rxjs'; @@ -66,9 +66,8 @@ export class WebPushService { } }); - this._authService.onBeforeSignOut - .pipe(takeUntilDestroyed()) - .subscribe(() => this.disable(true)); + const sub = this._authService.onBeforeSignOut(() => this.disable(true)); + inject(DestroyRef).onDestroy(() => sub.unsubscribe()); } public async enable(): Promise { @@ -100,6 +99,7 @@ export class WebPushService { } public async disable(keepSubscription: boolean = false): Promise { + console.log('disable'); if (!this.notificationsSupported) return; const subscription = this._subscription(); From 63336c4e8f7bf049b8b6d24daabbd1cf33a70b3d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Schmidt?= <9435005+AnSch1510@users.noreply.github.com> Date: Wed, 3 Jul 2024 22:04:14 +0200 Subject: [PATCH 10/12] add "send Push to user" feature --- .../user-settings.component.html | 4 +- .../user-settings/user-settings.component.ts | 7 +- .../user-push-dialog.component.html | 53 ++++++++++++ .../user-push-dialog.component.ts | 82 +++++++++++++++++++ .../app/components/users/users.component.html | 9 +- .../app/components/users/users.component.ts | 2 + src/client/src/app/i18n/de.json | 16 +++- src/client/src/app/i18n/en.json | 16 +++- src/client/src/styles.scss | 10 ++- 9 files changed, 186 insertions(+), 13 deletions(-) create mode 100644 src/client/src/app/components/users/user-push-dialog/user-push-dialog.component.html create mode 100644 src/client/src/app/components/users/user-push-dialog/user-push-dialog.component.ts diff --git a/src/client/src/app/components/user-settings/user-settings.component.html b/src/client/src/app/components/user-settings/user-settings.component.html index 0398f23..a03017a 100644 --- a/src/client/src/app/components/user-settings/user-settings.component.html +++ b/src/client/src/app/components/user-settings/user-settings.component.html @@ -109,14 +109,14 @@

{{ translations.nav_settings() }}

{{ - translations.settings_notifications_testNotification_successText() + translations.shared_sent() }}
diff --git a/src/client/src/app/components/user-settings/user-settings.component.ts b/src/client/src/app/components/user-settings/user-settings.component.ts index 28ab844..d129e13 100644 --- a/src/client/src/app/components/user-settings/user-settings.component.ts +++ b/src/client/src/app/components/user-settings/user-settings.component.ts @@ -172,7 +172,12 @@ export class UserSettingsComponent { protected async sendTestNotification() { this.sendPush.set(false); - const response = await this._notificationService.sendNotification({ body: {} }); + const response = await this._notificationService.sendNotification({ + body: { + title: this.translations.users_notificationDialog_titleDefault(), + body: this.translations.users_notificationDialog_bodyDefault(), + }, + }); if (response.ok) { this.sendPush.set(true); } diff --git a/src/client/src/app/components/users/user-push-dialog/user-push-dialog.component.html b/src/client/src/app/components/users/user-push-dialog/user-push-dialog.component.html new file mode 100644 index 0000000..3ea2f7e --- /dev/null +++ b/src/client/src/app/components/users/user-push-dialog/user-push-dialog.component.html @@ -0,0 +1,53 @@ + +
+
+ {{ translations.users_notificationDialog_sendTo() | interpolate: user() }} +
+ + + + + + + + + + +
+ + + + +
diff --git a/src/client/src/app/components/users/user-push-dialog/user-push-dialog.component.ts b/src/client/src/app/components/users/user-push-dialog/user-push-dialog.component.ts new file mode 100644 index 0000000..24c6517 --- /dev/null +++ b/src/client/src/app/components/users/user-push-dialog/user-push-dialog.component.ts @@ -0,0 +1,82 @@ +import { CommonModule } from '@angular/common'; +import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core'; +import { FormBuilder, ReactiveFormsModule } from '@angular/forms'; +import { AutoFocusModule } from 'primeng/autofocus'; +import { ButtonModule } from 'primeng/button'; +import { DialogModule } from 'primeng/dialog'; +import { InputTextModule } from 'primeng/inputtext'; +import { InputTextareaModule } from 'primeng/inputtextarea'; + +import { NotificationsService } from '../../../api/services'; +import { InterpolatePipe } from '../../../directives/interpolate.pipe'; +import { User } from '../../../models/parsed-models'; +import { TranslateService } from '../../../services/translate.service'; + +@Component({ + selector: 'app-user-push-dialog', + standalone: true, + imports: [ + AutoFocusModule, + ButtonModule, + CommonModule, + DialogModule, + InputTextModule, + InputTextareaModule, + InterpolatePipe, + ReactiveFormsModule, + ], + templateUrl: './user-push-dialog.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class UserPushDialogComponent { + protected readonly translations = inject(TranslateService).translations; + private readonly _randomId = Math.random().toString(36).substring(2, 9); + + private readonly _formBuilder = inject(FormBuilder); + private readonly _notificationsService = inject(NotificationsService); + + protected readonly visible = signal(false); + protected readonly isLoading = signal(false); + protected readonly user = signal(undefined); + protected readonly form = this._formBuilder.group({ + title: this._formBuilder.control(''), + body: this._formBuilder.control(''), + }); + + public open(user: User): void { + this.user.set(user); + this.visible.set(true); + } + + public close(): void { + this.visible.set(false); + } + + protected async submit() { + if (!this.form.valid) { + this.form.markAllAsTouched(); + return; + } + + const user = this.user(); + if (!user) return; + + this.isLoading.set(true); + try { + await this._notificationsService.sendNotification({ + body: { + userId: user.id, + title: this.form.value.title || this.translations.users_notificationDialog_titleDefault(), + body: this.form.value.body || this.translations.users_notificationDialog_bodyDefault(), + }, + }); + this.close(); + } finally { + this.isLoading.set(false); + } + } + + protected id(purpose: string) { + return `${purpose}-${this._randomId}`; + } +} diff --git a/src/client/src/app/components/users/users.component.html b/src/client/src/app/components/users/users.component.html index eb08bf3..4be8df9 100644 --- a/src/client/src/app/components/users/users.component.html +++ b/src/client/src/app/components/users/users.component.html @@ -38,7 +38,13 @@ > @if (user.hasPushSubscription) { - + } + diff --git a/src/client/src/app/components/users/users.component.ts b/src/client/src/app/components/users/users.component.ts index 7c7f666..01a2260 100644 --- a/src/client/src/app/components/users/users.component.ts +++ b/src/client/src/app/components/users/users.component.ts @@ -10,6 +10,7 @@ import { ProgressSpinnerModule } from 'primeng/progressspinner'; import { UserDialogComponent } from './user-dialog/user-dialog.component'; import { UserItemComponent } from './user-item/user-item.component'; +import { UserPushDialogComponent } from './user-push-dialog/user-push-dialog.component'; import { isActionBusy, hasActionFailed } from '../../+state/action-state'; import { keepUsersLoaded, @@ -37,6 +38,7 @@ function userMatchesFilter(map: User | undefined, lowerCaseFilter: string): map InputTextModule, MessagesModule, UserDialogComponent, + UserPushDialogComponent, UserItemComponent, ProgressSpinnerModule, ], diff --git a/src/client/src/app/i18n/de.json b/src/client/src/app/i18n/de.json index 6f4955b..71620b6 100644 --- a/src/client/src/app/i18n/de.json +++ b/src/client/src/app/i18n/de.json @@ -42,9 +42,7 @@ "enabledOnAllDevices": "Auf allen Geräten aktiviert", "eanbled": "Auf diesem Gerät aktiviert", "testNotification": { - "text": "Testbenachrichtigung", - "buttonText": "Senden", - "successText": "Gesendet" + "text": "Testbenachrichtigung" }, "errors": { "notSupported": "Benachrichtigungen werden von diesem Browser nicht unterstützt. Gegenebenfalls muss die Webseite auf dem Home-Bildschirm installiert werden.", @@ -199,6 +197,14 @@ }, "error": { "load": "Fehler beim Laden der Benutzer." + }, + "notificationDialog": { + "dialogTitle": "Spieler Benachrichtigen", + "sendTo": "senden an \"{{alias}}\"", + "title": "Titel", + "titleDefault": "Testbenachrichtigung", + "body": "Inhalt", + "bodyDefault": "Benachrichtigungen funktionieren!!! Wohoo ⛳" } }, "playerEvents": { @@ -252,7 +258,9 @@ "commit": "Freigeben", "minute": "Minute", "minutes": "Minuten", - "none": "Keine" + "none": "Keine", + "send": "Senden", + "sent": "Gesendet" }, "validation": { "required": "Darf nicht leer sein.", diff --git a/src/client/src/app/i18n/en.json b/src/client/src/app/i18n/en.json index df3645b..1c52325 100644 --- a/src/client/src/app/i18n/en.json +++ b/src/client/src/app/i18n/en.json @@ -42,9 +42,7 @@ "enabledOnAllDevices": "Enabled on all devices", "eanbled": "Enabled on this device", "testNotification": { - "text": "Test notification", - "buttonText": "Send", - "successText": "Sent" + "text": "Test notification" }, "errors": { "notSupported": "Notifications are not supported by this browser. Probably this website needs to be installed onto the homescreen.", @@ -199,6 +197,14 @@ }, "error": { "load": "Failed to load users." + }, + "notificationDialog": { + "dialogTitle": "Notify player", + "sendTo": "senden an \"{{alias}}\"", + "title": "title", + "titleDefault": "test notification", + "body": "body", + "bodyDefault": "Notifications are working!!! Wohoo ⛳" } }, "playerEvents": { @@ -252,7 +258,9 @@ "commit": "Commit", "minute": "Minute", "minutes": "Minutes", - "none": "None" + "none": "None", + "send": "send", + "sent": "sent" }, "validation": { "required": "Cannot be blank.", diff --git a/src/client/src/styles.scss b/src/client/src/styles.scss index b013742..f48ec8b 100644 --- a/src/client/src/styles.scss +++ b/src/client/src/styles.scss @@ -197,13 +197,21 @@ @apply px-3; } + input::placeholder, + textarea::placeholder { + opacity: 1; + } + input:focus, input.p-filled, textarea:focus, textarea.p-filled, .p-inputwrapper-focus, - .p-inputwrapper-filled { + .p-inputwrapper-filled, + input[placeholder], + textarea[placeholder] { ~ label { + font-size: 0.75rem; top: 2px; padding: 0 4px; left: 8px; From 2dd5d5a947342e8a5be5b9f5708fa00fddd65d5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Schmidt?= <9435005+AnSch1510@users.noreply.github.com> Date: Wed, 3 Jul 2024 22:26:42 +0200 Subject: [PATCH 11/12] fix integration test --- .../Administration/Users/UpdateUserEndpoint.cs | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/test/MinigolfFriday.IntegrationTests/Endpoints/Administration/Users/UpdateUserEndpoint.cs b/test/MinigolfFriday.IntegrationTests/Endpoints/Administration/Users/UpdateUserEndpoint.cs index 4c2b783..824a5bc 100644 --- a/test/MinigolfFriday.IntegrationTests/Endpoints/Administration/Users/UpdateUserEndpoint.cs +++ b/test/MinigolfFriday.IntegrationTests/Endpoints/Administration/Users/UpdateUserEndpoint.cs @@ -32,7 +32,8 @@ public async Task UpdateUser_Alias_Success(DatabaseProvider databaseProvider) Id = user.Id, Alias = "Renamed User", Roles = user.Roles, - PlayerPreferences = user.PlayerPreferences + PlayerPreferences = user.PlayerPreferences, + HasPushSubscription = false } ); } @@ -57,7 +58,8 @@ await sut.AppClient.UpdateUserAsync( Id = user.Id, Alias = user.Alias, Roles = [Role.Admin], - PlayerPreferences = user.PlayerPreferences + PlayerPreferences = user.PlayerPreferences, + HasPushSubscription = false } ); } @@ -97,7 +99,8 @@ await sut.AppClient.UpdateUserAsync( { Avoid = [users[2].User.Id], Prefer = [users[1].User.Id] - } + }, + HasPushSubscription = false } ); } @@ -137,7 +140,8 @@ await sut.AppClient.UpdateUserAsync( { Avoid = [users[0].User.Id], Prefer = [users[2].User.Id] - } + }, + HasPushSubscription = false } ); } @@ -182,7 +186,8 @@ await sut.AppClient.UpdateUserAsync( { Avoid = [users[2].User.Id], Prefer = [users[3].User.Id] - } + }, + HasPushSubscription = false } ); } From dd70e1ee3e8b3a9a471c63499ea978b45dd81980 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Schmidt?= <9435005+AnSch1510@users.noreply.github.com> Date: Wed, 3 Jul 2024 22:42:18 +0200 Subject: [PATCH 12/12] improve code written from noop --- .../Notifications/SendNotificationEndpoint.cs | 25 +++++++++---------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/src/server/host/Endpoints/Notifications/SendNotificationEndpoint.cs b/src/server/host/Endpoints/Notifications/SendNotificationEndpoint.cs index c0afef7..625195c 100644 --- a/src/server/host/Endpoints/Notifications/SendNotificationEndpoint.cs +++ b/src/server/host/Endpoints/Notifications/SendNotificationEndpoint.cs @@ -22,7 +22,7 @@ public class SendNotificationRequestValidator : Validator x.UserId).NotNull().ValidSqid(idService.User); + When(x => x.UserId != null, () => RuleFor(x => x.UserId!).ValidSqid(idService.User)); } } @@ -53,22 +53,21 @@ public override async Task HandleAsync(SendNotificationRequest req, Cancellation userId = idService.User.DecodeSingle(req.UserId); } - var user = null as UserEntity; - try - { - user = await databaseContext - .Users.Include(x => x.Settings) - .FirstAsync(x => x.Id == userId, ct); - } - catch (System.Exception) + var user = await databaseContext + .Users.Include(x => x.Settings) + .FirstOrDefaultAsync(x => x.Id == userId, ct); + + if (user == null) { - Logger.LogWarning(EndpointErrors.UserNotFound, req.UserId); - await this.SendErrorAsync(EndpointErrors.UserNotFound, req.UserId, ct); + Logger.LogWarning(EndpointErrors.UserNotFound, userId); + await this.SendErrorAsync( + EndpointErrors.UserNotFound, + idService.User.Encode(userId), + ct + ); return; } - Logger.LogWarning("send to userId: {} ", user.Alias); - var notifications = await databaseContext .Users.Where(u => u.Id == userId) .Select(u => new