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/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()) {
-
+
{{ translations.nav_settings() }}
@if (settings.enableNotifications && notificationsEnabled()) { +
+
+ {{ translations.settings_notifications_testNotification_text() }} +
+
+ + {{ + translations.shared_sent() + }} +
+

{{ 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..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 @@ -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')); @@ -132,6 +134,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 +145,7 @@ export class UserSettingsComponent { notifyEventStart: this.getSaveState('notifyOnEventStart'), notifyEventUpdate: this.getSaveState('notifyOnEventUpdated'), notifyTimeslotStart: this.getSaveState('notifyOnTimeslotStart'), + testSend: toObservable(this.sendPush), }; constructor() { @@ -166,6 +170,19 @@ export class UserSettingsComponent { this._store.dispatch(updateUserSettingsAction(changes)); } + protected async sendTestNotification() { + this.sendPush.set(false); + 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); + } + } + protected async toggleNotifications(enabled: boolean) { if (!this.notificationsPossible) return; this.isUpdatingPushSubscription.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 2c440a8..4be8df9 100644 --- a/src/client/src/app/components/users/users.component.html +++ b/src/client/src/app/components/users/users.component.html @@ -37,6 +37,15 @@ [class.border-t-[1px]]="index > 0" > + @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 2685084..71620b6 100644 --- a/src/client/src/app/i18n/de.json +++ b/src/client/src/app/i18n/de.json @@ -41,6 +41,9 @@ "title": "Benachrichtigungen", "enabledOnAllDevices": "Auf allen Geräten aktiviert", "eanbled": "Auf diesem Gerät aktiviert", + "testNotification": { + "text": "Testbenachrichtigung" + }, "errors": { "notSupported": "Benachrichtigungen werden von diesem Browser nicht unterstützt. Gegenebenfalls muss die Webseite auf dem Home-Bildschirm installiert werden.", "notGranted": { @@ -194,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": { @@ -247,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 1b3c642..1c52325 100644 --- a/src/client/src/app/i18n/en.json +++ b/src/client/src/app/i18n/en.json @@ -41,6 +41,9 @@ "title": "Notifications", "enabledOnAllDevices": "Enabled on all devices", "eanbled": "Enabled on this device", + "testNotification": { + "text": "Test notification" + }, "errors": { "notSupported": "Notifications are not supported by this browser. Probably this website needs to be installed onto the homescreen.", "notGranted": { @@ -194,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": { @@ -247,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/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(); 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; 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/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/SendNotificationEndpoint.cs b/src/server/host/Endpoints/Notifications/SendNotificationEndpoint.cs new file mode 100644 index 0000000..625195c --- /dev/null +++ b/src/server/host/Endpoints/Notifications/SendNotificationEndpoint.cs @@ -0,0 +1,100 @@ +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) + { + When(x => x.UserId != null, () => RuleFor(x => x.UserId!).ValidSqid(idService.User)); + } +} + +public class SendNotificationEndpoint( + DatabaseContext databaseContext, + IJwtService jwtService, + IIdService idService, + 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() && jwtService.HasRole(User, Role.Admin)) + { + userId = idService.User.DecodeSingle(req.UserId); + } + + var user = await databaseContext + .Users.Include(x => x.Settings) + .FirstOrDefaultAsync(x => x.Id == userId, ct); + + if (user == null) + { + Logger.LogWarning(EndpointErrors.UserNotFound, userId); + await this.SendErrorAsync( + EndpointErrors.UserNotFound, + idService.User.Encode(userId), + ct + ); + return; + } + + 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( + string.IsNullOrEmpty(req.Title) + ? "Dies ist eine Testbenachrichtigung" + : req.Title, + string.IsNullOrEmpty(req.Body) ? "Testbenachrichtigung" : req.Body + ) + }) + .ToArrayAsync(ct); + foreach (var notification in notifications) + await webPushService.SendAsync( + notification.Subscriptions, + notification.NotificationData, + ct + ); + + await SendOkAsync(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/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)!); + } } 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( 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 } ); }