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
}
);
}