@for (player of players(); track player.id; let index = $index) {
0"
>
- @if (player.alias) {
- {{ player.alias }}
- } @else {
- <{{ translations.events_timeslot_unknownPlayer() }}>
-
({{ player.id }})
- }
+
+ @if (player.alias) {
+ {{ player.alias }}
+ } @else {
+ <{{ translations.events_timeslot_unknownPlayer() }}>
+ ({{ player.id }})
+ }
+
+
}
@if (timeslot.playerIds.length === 0) {
diff --git a/src/client/src/app/components/events/event-timeslot/event-timeslot.component.ts b/src/client/src/app/components/events/event-timeslot/event-timeslot.component.ts
index e98ac9d..0f1f3f1 100644
--- a/src/client/src/app/components/events/event-timeslot/event-timeslot.component.ts
+++ b/src/client/src/app/components/events/event-timeslot/event-timeslot.component.ts
@@ -6,11 +6,13 @@ import { Router, ActivatedRoute } from '@angular/router';
import { Actions, ofType } from '@ngrx/effects';
import { Store } from '@ngrx/store';
import { AccordionModule } from 'primeng/accordion';
-import { ConfirmationService } from 'primeng/api';
+import { ConfirmationService, MessageService } from 'primeng/api';
import { ButtonModule } from 'primeng/button';
import { CardModule } from 'primeng/card';
import { DropdownModule } from 'primeng/dropdown';
+import { ListboxModule } from 'primeng/listbox';
import { MessagesModule } from 'primeng/messages';
+import { OverlayPanelModule } from 'primeng/overlaypanel';
import { ProgressSpinnerModule } from 'primeng/progressspinner';
import { TooltipModule } from 'primeng/tooltip';
import { map } from 'rxjs';
@@ -35,16 +37,19 @@ import {
selectUsersActionState,
userSelectors,
} from '../../../+state/users';
+import { EventsService } from '../../../api/services';
import { EventInstancePreconfiguration, User } from '../../../models/parsed-models';
import { TranslateService } from '../../../services/translate.service';
-import { ifTruthy, isNullish } from '../../../utils/common.utils';
+import { ifTruthy, isNullish, notNullish } from '../../../utils/common.utils';
import { dateWithTime, timeToString } from '../../../utils/date.utils';
import { errorToastEffect, selectSignal } from '../../../utils/ngrx.utils';
+import { UserItemComponent } from '../../users/user-item/user-item.component';
import { EventTimeslotDialogComponent } from '../event-timeslot-dialog/event-timeslot-dialog.component';
function asString(value: unknown): string | null {
return typeof value === 'string' ? value : null;
}
+type EventPlayer = Partial
& { id: string };
@Component({
selector: 'app-event-timeslot',
@@ -57,9 +62,12 @@ function asString(value: unknown): string | null {
DropdownModule,
EventTimeslotDialogComponent,
FormsModule,
+ ListboxModule,
MessagesModule,
+ OverlayPanelModule,
ProgressSpinnerModule,
TooltipModule,
+ UserItemComponent,
],
templateUrl: './event-timeslot.component.html',
styleUrl: './event-timeslot.component.scss',
@@ -71,6 +79,8 @@ export class EventTimeslotComponent {
private readonly _activatedRoute = inject(ActivatedRoute);
private readonly _translateService = inject(TranslateService);
private readonly _confirmationService = inject(ConfirmationService);
+ private readonly _eventService = inject(EventsService);
+ private readonly _messageService = inject(MessageService);
protected readonly translations = this._translateService.translations;
protected readonly locale = this._translateService.language;
@@ -110,11 +120,16 @@ export class EventTimeslotComponent {
this.timeslot(),
timeslot =>
timeslot.playerIds
- .map & { id: string }>(x => this.allUsers()[x] ?? { id: x })
+ .map(x => this.allUsers()[x] ?? { id: x })
.sort((a, b) => (a?.alias ?? '').localeCompare(b?.alias ?? '')),
[]
)
);
+ protected readonly availablePlayers = computed(() =>
+ Object.values(this.allUsers())
+ .filter(notNullish)
+ .filter(u => !this.players().some(p => p.id === u.id))
+ );
protected readonly preconfigPlayerOptions = computed(() =>
this.players().filter(
x => !this.timeslot()?.preconfigurations.some(p => p.playerIds.includes(x.id))
@@ -168,6 +183,67 @@ export class EventTimeslotComponent {
}
}
+ protected async addPlayer(userId: string) {
+ const eventId = this.eventId();
+ const timeslotId = this.timeslotId();
+
+ if (isNullish(eventId) || isNullish(timeslotId)) return;
+
+ const response = await this._eventService.patchPlayerEventRegistrations({
+ eventId,
+ body: { userId: userId, timeslotId: timeslotId, isRegistered: true },
+ });
+ if (response.ok) {
+ this._messageService.add({
+ severity: 'success',
+ summary: this.translations.events_timeslot_playerAdded(),
+ life: 2000,
+ });
+ } else {
+ this._messageService.add({
+ severity: 'error',
+ summary: this.translations.events_timeslot_error_playerAdded(),
+ life: 2000,
+ });
+ }
+ }
+
+ protected async removeUser(user: EventPlayer) {
+ const eventId = this.eventId();
+ const timeslotId = this.timeslotId();
+
+ if (isNullish(eventId) || isNullish(timeslotId)) return;
+
+ this._confirmationService.confirm({
+ header: this.translations.events_timeslot_playerRemoveDialog_header(user),
+ message: this.translations.events_timeslot_playerRemoveDialog_message(),
+ acceptLabel: this.translations.shared_delete(),
+ acceptButtonStyleClass: 'p-button-danger',
+ acceptIcon: 'p-button-icon-left i-[mdi--delete]',
+ rejectLabel: this.translations.shared_cancel(),
+ rejectButtonStyleClass: 'p-button-text',
+ accept: async () => {
+ const response = await this._eventService.patchPlayerEventRegistrations({
+ eventId,
+ body: { userId: user.id, timeslotId: timeslotId, isRegistered: false },
+ });
+ if (response.ok) {
+ this._messageService.add({
+ severity: 'success',
+ summary: this.translations.events_timeslot_playerRemoveDialog_playerRemoved(),
+ life: 2000,
+ });
+ } else {
+ this._messageService.add({
+ severity: 'error',
+ summary: this.translations.events_timeslot_playerRemoveDialog_error_playerRemoved(),
+ life: 2000,
+ });
+ }
+ },
+ });
+ }
+
protected addPreconfig() {
const eventId = this.eventId();
const timeslotId = this.timeslotId();
diff --git a/src/client/src/app/i18n/de.json b/src/client/src/app/i18n/de.json
index 1722956..c674efb 100644
--- a/src/client/src/app/i18n/de.json
+++ b/src/client/src/app/i18n/de.json
@@ -101,7 +101,19 @@
"time": "Startzeit",
"mapToPlay": "Zu spielende Bahn",
"map": "Bahn",
- "isFallbackAllowed": "Spieler können einen Ausweichzeitslot festlegen"
+ "isFallbackAllowed": "Spieler können einen Ausweichzeitslot festlegen",
+ "playerAdded": "Spieler wurde diesem Zeitslot hinzugefügt",
+ "playerRemoveDialog": {
+ "header": "Spieler \"{{alias}}\" entfernen?",
+ "message": "Möchtest du den Spieler wirklich aus dem Zeitslot entfernen?",
+ "playerRemoved": "Spieler wurde aus diesem Zeitslot entfernt",
+ "error": {
+ "playerRemoved": "Fehler beim entfernen aufgetreten."
+ }
+ },
+ "error": {
+ "playerAdded": "Fehler beim hinzufpgen aufgetreten."
+ }
},
"createDialog": {
"title": "Veranstaltung erstellen",
diff --git a/src/client/src/app/i18n/en.json b/src/client/src/app/i18n/en.json
index ca65307..43f4488 100644
--- a/src/client/src/app/i18n/en.json
+++ b/src/client/src/app/i18n/en.json
@@ -101,7 +101,19 @@
"time": "Start time",
"mapToPlay": "Map to be played",
"map": "Map",
- "isFallbackAllowed": "Players can define a fallback timeslot"
+ "isFallbackAllowed": "Players can define a fallback timeslot",
+ "playerAdded": "Player has been added to this timeslot",
+ "playerRemoveDialog": {
+ "header": "Remove Player \"{{alias}}\"",
+ "message": "Do you really want to remove the player from this timeslot?",
+ "playerRemoved": "Player has been removed from this timeslot",
+ "error": {
+ "playerRemoved": "Error while removing Ppayer from timeslot"
+ }
+ },
+ "error": {
+ "playerAdded": "Error while adding player to timeslot"
+ }
},
"createDialog": {
"title": "Create event",
diff --git a/src/server/host/Endpoints/Events/EventsAdministrationGroup.cs b/src/server/host/Endpoints/Events/EventsAdministrationGroup.cs
new file mode 100644
index 0000000..270f922
--- /dev/null
+++ b/src/server/host/Endpoints/Events/EventsAdministrationGroup.cs
@@ -0,0 +1,19 @@
+using FastEndpoints;
+using MinigolfFriday.Domain.Models;
+
+namespace MinigolfFriday.Host.Endpoints.Events;
+
+public class EventsAdministrationGroup : Group
+{
+ public EventsAdministrationGroup()
+ {
+ Configure(
+ "events",
+ x =>
+ {
+ x.Roles(nameof(Role.Admin));
+ x.Description(x => x.WithTags("Events"));
+ }
+ );
+ }
+}
diff --git a/src/server/host/Endpoints/Events/PatchPlayerEventRegistrationsEndpoint.cs b/src/server/host/Endpoints/Events/PatchPlayerEventRegistrationsEndpoint.cs
new file mode 100644
index 0000000..b2a0d71
--- /dev/null
+++ b/src/server/host/Endpoints/Events/PatchPlayerEventRegistrationsEndpoint.cs
@@ -0,0 +1,144 @@
+using System.ComponentModel.DataAnnotations;
+using FastEndpoints;
+using FluentValidation;
+using MaSch.Core.Extensions;
+using Microsoft.EntityFrameworkCore;
+using MinigolfFriday.Data;
+using MinigolfFriday.Data.Entities;
+using MinigolfFriday.Domain.Models.RealtimeEvents;
+using MinigolfFriday.Host.Common;
+using MinigolfFriday.Host.Services;
+
+namespace MinigolfFriday.Host.Endpoints.Events;
+
+/// The id of the event to change registration.
+/// The registration to change.
+/// The registration state to change the timeslot to.
+/// The userId to patch. Only available as Admin.
+public record PatchPlayerEventRegistrationsRequest(
+ [property: Required] string EventId,
+ [property: Required] string TimeslotId,
+ [property: Required] bool IsRegistered,
+ [property: Required] string UserId
+);
+
+public class PatchPlayerEventRegistrationsRequestValidator
+ : Validator
+{
+ public PatchPlayerEventRegistrationsRequestValidator(IIdService idService)
+ {
+ RuleFor(x => x.EventId).NotEmpty().ValidSqid(idService.Event);
+ RuleFor(x => x.TimeslotId).NotEmpty().ValidSqid(idService.EventTimeslot);
+ }
+}
+
+public class PatchPlayerEventRegistrationsEndpoint(
+ DatabaseContext databaseContext,
+ IRealtimeEventsService realtimeEventsService,
+ IIdService idService
+) : Endpoint
+{
+ public override void Configure()
+ {
+ Patch("{eventId}/registrations");
+ Group();
+ this.ProducesErrors(
+ EndpointErrors.UserIdNotInClaims,
+ EndpointErrors.EventNotFound,
+ EndpointErrors.EventRegistrationElapsed,
+ EndpointErrors.EventAlreadyStarted,
+ EndpointErrors.UserNotFound
+ );
+ }
+
+ public override async Task HandleAsync(
+ PatchPlayerEventRegistrationsRequest req,
+ CancellationToken ct
+ )
+ {
+ var userId = idService.User.DecodeSingle(req.UserId);
+
+ var user = await databaseContext.Users.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 eventId = idService.Event.DecodeSingle(req.EventId);
+ var eventInfo = await databaseContext
+ .Events.Where(x => x.Id == eventId)
+ .Select(x => new { Started = x.StartedAt != null, x.RegistrationDeadline })
+ .FirstOrDefaultAsync(ct);
+
+ if (eventInfo == null)
+ {
+ Logger.LogWarning(EndpointErrors.EventNotFound, eventId);
+ await this.SendErrorAsync(EndpointErrors.EventNotFound, req.EventId, ct);
+ return;
+ }
+
+ var registrations = await databaseContext
+ .EventTimeslotRegistrations.Where(x =>
+ x.Player.Id == userId && x.EventTimeslot.EventId == eventId
+ )
+ .ToArrayAsync(ct);
+ var targetRegistration = new
+ {
+ TimeslotId = idService.EventTimeslot.DecodeSingle(req.TimeslotId)
+ };
+ var timeslotToModify = registrations.FirstOrDefault(x =>
+ x.EventTimeslotId == targetRegistration.TimeslotId
+ );
+ if (timeslotToModify == null && req.IsRegistered)
+ {
+ // not existent but wants to -> then add
+ databaseContext.EventTimeslotRegistrations.Add(
+ new EventTimeslotRegistrationEntity
+ {
+ EventTimeslot = databaseContext.EventTimeslotById(
+ targetRegistration.TimeslotId
+ ),
+ Player = databaseContext.UserById(userId),
+ FallbackEventTimeslot = null
+ }
+ );
+ }
+ else if (timeslotToModify != null && !req.IsRegistered)
+ {
+ // is existent but does not want to -> remove
+ databaseContext.EventTimeslotRegistrations.RemoveRange(
+ registrations.Where(x => targetRegistration.TimeslotId == x.EventTimeslotId)
+ );
+ }
+ await databaseContext.SaveChangesAsync(ct);
+ await SendAsync(null, cancellation: ct);
+
+ await realtimeEventsService.SendEventAsync(
+ new RealtimeEvent.PlayerEventRegistrationChanged(
+ idService.User.Encode(userId),
+ idService.Event.Encode(eventId)
+ ),
+ ct
+ );
+ var userAlias = await databaseContext
+ .Users.Where(x => x.Id == userId)
+ .Select(x => x.Alias)
+ .FirstOrDefaultAsync(ct);
+ await realtimeEventsService.SendEventAsync(
+ new RealtimeEvent.PlayerEventTimeslotRegistrationChanged(
+ idService.Event.Encode(eventId),
+ idService.EventTimeslot.Encode(targetRegistration.TimeslotId),
+ idService.User.Encode(userId),
+ userAlias,
+ req.IsRegistered
+ ),
+ ct
+ );
+ }
+}
diff --git a/src/server/host/Endpoints/Notifications/SendNotificationEndpoint.cs b/src/server/host/Endpoints/Notifications/SendNotificationEndpoint.cs
index 625195c..a951bf1 100644
--- a/src/server/host/Endpoints/Notifications/SendNotificationEndpoint.cs
+++ b/src/server/host/Endpoints/Notifications/SendNotificationEndpoint.cs
@@ -48,14 +48,12 @@ public override async Task HandleAsync(SendNotificationRequest req, Cancellation
await this.SendErrorAsync(EndpointErrors.UserIdNotInClaims, ct);
return;
}
- if (!req.UserId.IsNullOrEmpty() && jwtService.HasRole(User, Role.Admin))
+ if (req.UserId != null && 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);
+ var user = await databaseContext.Users.FirstOrDefaultAsync(x => x.Id == userId, ct);
if (user == null)
{