diff --git a/src/client/src/app/components/events/event-timeslot/event-timeslot.component.html b/src/client/src/app/components/events/event-timeslot/event-timeslot.component.html index 9231df0..cb44d70 100644 --- a/src/client/src/app/components/events/event-timeslot/event-timeslot.component.html +++ b/src/client/src/app/components/events/event-timeslot/event-timeslot.component.html @@ -186,21 +186,63 @@

} -

- {{ translations.events_timeslot_registeredPlayers() }} ({{ timeslot.playerIds.length }}) -

+
+

+ {{ translations.events_timeslot_registeredPlayers() }} +

+ + {{ timeslot.playerIds.length }} + + @if (!event.startedAt) { + + + } +
+
@for (player of players(); track player.id; let index = $index) {
- @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) {