From 7c2fb94a809c807369072445aae8213dbcb66c88 Mon Sep 17 00:00:00 2001 From: Marc Schmidt Date: Sat, 27 Jul 2024 14:25:12 +0200 Subject: [PATCH] feat: add server endpoint for editing event instances Refs: #24 --- .../actions/set-event-instances.action.ts | 60 ++++++ .../src/app/+state/events/events.actions.ts | 7 +- .../src/app/+state/events/events.effects.ts | 2 + .../src/app/+state/events/events.reducer.ts | 6 +- .../src/app/+state/events/events.state.ts | 2 + .../event-details.component.html | 28 +-- .../event-instances-dialog.component.ts | 41 +++- src/client/src/app/i18n/de.json | 3 +- src/client/src/app/i18n/en.json | 3 +- .../Instances/PutEventInstancesEndpoint.cs | 192 ++++++++++++++++++ 10 files changed, 319 insertions(+), 25 deletions(-) create mode 100644 src/client/src/app/+state/events/actions/set-event-instances.action.ts create mode 100644 src/server/host/Endpoints/Administration/Events/Instances/PutEventInstancesEndpoint.cs diff --git a/src/client/src/app/+state/events/actions/set-event-instances.action.ts b/src/client/src/app/+state/events/actions/set-event-instances.action.ts new file mode 100644 index 0000000..60e3111 --- /dev/null +++ b/src/client/src/app/+state/events/actions/set-event-instances.action.ts @@ -0,0 +1,60 @@ +import { inject } from '@angular/core'; +import { on } from '@ngrx/store'; +import { produce, castDraft } from 'immer'; +import { switchMap } from 'rxjs'; + +import { ApiEventTimeslotInstances } from '../../../api/models'; +import { EventAdministrationService } from '../../../api/services'; +import { createHttpAction, handleHttpAction, onHttpAction, toHttpAction } from '../../action-state'; +import { createFunctionalEffect } from '../../functional-effect'; +import { Effects, Reducers } from '../../utils'; +import { EVENTS_ACTION_SCOPE } from '../consts'; +import { eventEntityAdapter, EventsFeatureState } from '../events.state'; + +export const setEventInstancesAction = createHttpAction<{ + eventId: string; + instances: ApiEventTimeslotInstances[]; +}>()(EVENTS_ACTION_SCOPE, 'Set Event Instances'); + +export const setEventInstancesReducers: Reducers = [ + on(setEventInstancesAction.success, (state, { props }) => + eventEntityAdapter.mapOne( + { + id: props.eventId, + map: produce(draft => { + for (const timeslot of draft.timeslots) { + timeslot.instances = castDraft( + props.instances.find(x => x.timeslotId === timeslot.id)?.instances || [] + ); + } + }), + }, + state + ) + ), + handleHttpAction('setInstances', setEventInstancesAction), +]; + +export const setEventInstancesEffects: Effects = { + setEventInstances$: createFunctionalEffect.dispatching( + (api = inject(EventAdministrationService)) => + onHttpAction(setEventInstancesAction).pipe( + switchMap(({ props }) => + toHttpAction(setEventInstances(api, props), setEventInstancesAction, props) + ) + ) + ), +}; + +async function setEventInstances( + api: EventAdministrationService, + props: ReturnType['props'] +) { + const response = await api.putEventInstances({ + eventId: props.eventId, + body: { instances: props.instances }, + }); + return response.ok + ? setEventInstancesAction.success(props, undefined) + : setEventInstancesAction.error(props, response); +} diff --git a/src/client/src/app/+state/events/events.actions.ts b/src/client/src/app/+state/events/events.actions.ts index d4424e1..8b69982 100644 --- a/src/client/src/app/+state/events/events.actions.ts +++ b/src/client/src/app/+state/events/events.actions.ts @@ -1,16 +1,17 @@ export { addEventPreconfigAction } from './actions/add-event-preconfig.action'; export { addEventTimeslotAction } from './actions/add-event-timeslot.action'; export { addEventAction } from './actions/add-event.action'; -export { updateEventAction } from './actions/update-event.action'; -export { eventTimeslotRegistrationChangedAction } from './actions/event-timeslot-registration-changed.action'; export { addPlayerToEventPreconfigurationAction } from './actions/add-player-to-preconfig.action'; export { buildEventInstancesAction } from './actions/build-event-instances.action'; +export { eventTimeslotRegistrationChangedAction } from './actions/event-timeslot-registration-changed.action'; export { loadEventAction } from './actions/load-event.action'; export { loadEventsAction } from './actions/load-events.action'; -export { removeEventAction } from './actions/remove-event.action'; export { removeEventPreconfigAction } from './actions/remove-event-preconfig.action'; export { removeEventTimeslotAction } from './actions/remove-event-timeslot.action'; +export { removeEventAction } from './actions/remove-event.action'; export { removePlayerFromPreconfigAction } from './actions/remove-player-from-preconfig.action'; export { resetEventsActionStateAction } from './actions/reset-events-action-state.action'; +export { setEventInstancesAction } from './actions/set-event-instances.action'; export { startEventAction } from './actions/start-event.action'; export { updateEventTimeslotAction } from './actions/update-event-timeslot.action'; +export { updateEventAction } from './actions/update-event.action'; diff --git a/src/client/src/app/+state/events/events.effects.ts b/src/client/src/app/+state/events/events.effects.ts index b7e068c..24162ce 100644 --- a/src/client/src/app/+state/events/events.effects.ts +++ b/src/client/src/app/+state/events/events.effects.ts @@ -12,6 +12,7 @@ import { removeEventPreconfigEffects } from './actions/remove-event-preconfig.ac import { removeEventTimeslotEffects } from './actions/remove-event-timeslot.action'; import { removeEventAction, removeEventEffects } from './actions/remove-event.action'; import { removePlayerFromPreconfigEffects } from './actions/remove-player-from-preconfig.action'; +import { setEventInstancesEffects } from './actions/set-event-instances.action'; import { startEventEffects } from './actions/start-event.action'; import { updateEventTimeslotEffects } from './actions/update-event-timeslot.action'; import { updateEventEffects } from './actions/update-event.action'; @@ -35,6 +36,7 @@ export const eventsFeatureEffects: Effects[] = [ removeEventTimeslotEffects, removeEventEffects, removePlayerFromPreconfigEffects, + setEventInstancesEffects, startEventEffects, updateEventEffects, updateEventTimeslotEffects, diff --git a/src/client/src/app/+state/events/events.reducer.ts b/src/client/src/app/+state/events/events.reducer.ts index 2e679f1..d502ed7 100644 --- a/src/client/src/app/+state/events/events.reducer.ts +++ b/src/client/src/app/+state/events/events.reducer.ts @@ -13,6 +13,7 @@ import { removeEventTimeslotReducers } from './actions/remove-event-timeslot.act import { removeEventReducers } from './actions/remove-event.action'; import { removePlayerFromPreconfigReducers } from './actions/remove-player-from-preconfig.action'; import { resetEventsActionStateReducers } from './actions/reset-events-action-state.action'; +import { setEventInstancesReducers } from './actions/set-event-instances.action'; import { startEventReducers } from './actions/start-event.action'; import { updateEventTimeslotReducers } from './actions/update-event-timeslot.action'; import { updateEventReducers } from './actions/update-event.action'; @@ -26,7 +27,6 @@ export const eventsReducer = createReducer( ...addEventReducers, ...addPlayerToEventPreconfigurationReducers, ...buildEventInstancesReducers, - ...updateEventReducers, ...eventTimeslotRegistrationChangedReducers, ...loadEventReducers, ...loadEventsReducers, @@ -35,6 +35,8 @@ export const eventsReducer = createReducer( ...removeEventReducers, ...removePlayerFromPreconfigReducers, ...resetEventsActionStateReducers, + ...setEventInstancesReducers, ...startEventReducers, - ...updateEventTimeslotReducers + ...updateEventTimeslotReducers, + ...updateEventReducers ); diff --git a/src/client/src/app/+state/events/events.state.ts b/src/client/src/app/+state/events/events.state.ts index b36c097..78748c7 100644 --- a/src/client/src/app/+state/events/events.state.ts +++ b/src/client/src/app/+state/events/events.state.ts @@ -15,6 +15,7 @@ export type EventsFeatureState = EntityState & { addTimeslot: ActionState; removeTimeslot: ActionState; buildInstances: ActionState; + setInstances: ActionState; updateTimeslot: ActionState; addPreconfig: ActionState; removePreconfig: ActionState; @@ -40,6 +41,7 @@ export const initialEventsFeatureState: EventsFeatureState = eventEntityAdapter. addTimeslot: initialActionState, removeTimeslot: initialActionState, buildInstances: initialActionState, + setInstances: initialActionState, updateTimeslot: initialActionState, addPreconfig: initialActionState, removePreconfig: initialActionState, diff --git a/src/client/src/app/components/events/event-details/event-details.component.html b/src/client/src/app/components/events/event-details/event-details.component.html index a10e6ee..31b9dd8 100644 --- a/src/client/src/app/components/events/event-details/event-details.component.html +++ b/src/client/src/app/components/events/event-details/event-details.component.html @@ -152,8 +152,8 @@

{{ translations.events_timeslots() }}

@if (canBuildInstances()) {

{{ translations.events_groups() }}

- @if (!event.startedAt) { - + + @if (!event.startedAt) { - @if (hasInstances()) { - - } - - } + } + @if (hasInstances()) { + + } +
@for (timeslot of timeslots(); track timeslot.id) { diff --git a/src/client/src/app/components/events/event-instances-dialog/event-instances-dialog.component.ts b/src/client/src/app/components/events/event-instances-dialog/event-instances-dialog.component.ts index c9f8c5c..e7d8bde 100644 --- a/src/client/src/app/components/events/event-instances-dialog/event-instances-dialog.component.ts +++ b/src/client/src/app/components/events/event-instances-dialog/event-instances-dialog.component.ts @@ -1,18 +1,24 @@ import { CommonModule } from '@angular/common'; import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { Actions, ofType } from '@ngrx/effects'; +import { Store } from '@ngrx/store'; import { produce } from 'immer'; import { ButtonModule } from 'primeng/button'; import { DialogModule } from 'primeng/dialog'; import { InputGroupModule } from 'primeng/inputgroup'; import { ListboxModule } from 'primeng/listbox'; import { OverlayPanelModule } from 'primeng/overlaypanel'; +import { filter } from 'rxjs'; +import { isActionBusy } from '../../../+state/action-state'; +import { selectEventsActionState, setEventInstancesAction } from '../../../+state/events'; import { userSelectors } from '../../../+state/users'; import { Event, EventInstance, EventTimeslot } from '../../../models/parsed-models'; import { Logger } from '../../../services/logger.service'; import { TranslateService } from '../../../services/translate.service'; import { notNullish } from '../../../utils/common.utils'; -import { selectSignal } from '../../../utils/ngrx.utils'; +import { errorToastEffect, selectSignal } from '../../../utils/ngrx.utils'; import { UserItemComponent } from '../../users/user-item/user-item.component'; type EventInstances = { timeslot: EventTimeslot; instances: EventInstance[] }[]; @@ -33,6 +39,7 @@ type EventInstances = { timeslot: EventTimeslot; instances: EventInstance[] }[]; changeDetection: ChangeDetectionStrategy.OnPush, }) export class EventInstancesDialogComponent { + private readonly _store = inject(Store); protected readonly translations = inject(TranslateService).translations; private readonly _removeItem: EventInstance = { @@ -40,8 +47,10 @@ export class EventInstancesDialogComponent { groupCode: '', playerIds: [], }; + private readonly _actionState = selectSignal(selectEventsActionState('setInstances')); protected readonly visible = signal(false); + protected readonly event = signal(null); protected readonly instances = signal([]); protected readonly allUsers = selectSignal(userSelectors.selectEntities); protected readonly unassignedUsers = computed(() => @@ -90,9 +99,23 @@ export class EventInstancesDialogComponent { ) ); - protected readonly isBusy = signal(false); + protected readonly isBusy = computed(() => isActionBusy(this._actionState())); + + constructor() { + errorToastEffect(this.translations.events_error_changeGroups, this._actionState); + + const actions$ = inject(Actions); + actions$ + .pipe( + ofType(setEventInstancesAction.success), + filter(({ props }) => props.eventId === this.event()?.id), + takeUntilDestroyed() + ) + .subscribe(() => this.visible.set(false)); + } public open(event: Event) { + this.event.set(event); this.instances.set( event.timeslots.map(timeslot => ({ timeslot, instances: timeslot.instances })) ); @@ -138,7 +161,17 @@ export class EventInstancesDialogComponent { } protected submit() { - Logger.logDebug('EventInstancesDialogComponent', 'submit', { instances: this.instances() }); - // TODO: Implement Server-side logic and dispatch action + const event = this.event(); + const instances = this.instances(); + Logger.logDebug('EventInstancesDialogComponent', 'submit', { event, instances }); + + if (!event) return; + + this._store.dispatch( + setEventInstancesAction({ + eventId: event.id, + instances: instances.map(x => ({ timeslotId: x.timeslot.id, instances: x.instances })), + }) + ); } } diff --git a/src/client/src/app/i18n/de.json b/src/client/src/app/i18n/de.json index 383ae4b..cddb82f 100644 --- a/src/client/src/app/i18n/de.json +++ b/src/client/src/app/i18n/de.json @@ -155,7 +155,8 @@ "deleteTimeslot": "Fehler beim Löschen des Zeitslots.", "deletePreconfig": "Fehler beim Löschen der vordefinierten Gruppe.", "start": "Fehler beim Starten der Veranstaltung.", - "buildGroups": "Fehler beim Bilden der Gruppen." + "buildGroups": "Fehler beim Bilden der Gruppen.", + "changeGroups": "Fehler beim Ändern der Gruppen." } }, "maps": { diff --git a/src/client/src/app/i18n/en.json b/src/client/src/app/i18n/en.json index cf58804..4ef7e9d 100644 --- a/src/client/src/app/i18n/en.json +++ b/src/client/src/app/i18n/en.json @@ -155,7 +155,8 @@ "deleteTimeslot": "Failed to delete timeslot.", "deletePreconfig": "Failed to delete preconfigured group.", "start": "Failed to start event.", - "buildGroups": "Failed to build groups." + "buildGroups": "Failed to build groups.", + "changeGroups": "Failed to change groups." } }, "maps": { diff --git a/src/server/host/Endpoints/Administration/Events/Instances/PutEventInstancesEndpoint.cs b/src/server/host/Endpoints/Administration/Events/Instances/PutEventInstancesEndpoint.cs new file mode 100644 index 0000000..c149ebb --- /dev/null +++ b/src/server/host/Endpoints/Administration/Events/Instances/PutEventInstancesEndpoint.cs @@ -0,0 +1,192 @@ +using System.ComponentModel.DataAnnotations; +using FastEndpoints; +using FluentValidation; +using MaSch.Core.Extensions; +using Microsoft.EntityFrameworkCore; +using MinigolfFriday.Data; +using MinigolfFriday.Domain.Models; +using MinigolfFriday.Domain.Models.Push; +using MinigolfFriday.Domain.Models.RealtimeEvents; +using MinigolfFriday.Host.Common; +using MinigolfFriday.Host.Services; + +namespace MinigolfFriday.Host.Endpoints.Administration.Events.Instances; + +/// The id of the event to set the instances. +/// The instances to set for the event. +public record PutEventInstancesRequest( + [property: Required] string EventId, + [property: Required] EventTimeslotInstances[] Instances +); + +public class PutEventInstancesRequestValidator : Validator +{ + public PutEventInstancesRequestValidator(IIdService idService) + { + RuleFor(x => x.EventId).NotEmpty().ValidSqid(idService.Event); + RuleFor(x => x.Instances).NotEmpty(); + } +} + +/// Set the instances of an event. +public class PutEventInstancesEndpoint( + DatabaseContext databaseContext, + IRealtimeEventsService realtimeEventsService, + IIdService idService, + IEventInstanceService eventInstanceService, + IWebPushService webPushService +) : Endpoint +{ + public override void Configure() + { + Put("{eventId}/instances"); + Group(); + this.ProducesErrors( + EndpointErrors.EventNotFound, + EndpointErrors.EventRegistrationNotElapsed + ); + } + + public override async Task HandleAsync(PutEventInstancesRequest req, CancellationToken ct) + { + var eventId = idService.Event.DecodeSingle(req.EventId); + var eventInfo = await databaseContext + .Events.Where(x => x.Id == eventId) + .Select(e => new + { + Instances = e + .Timeslots.Select(t => new EventTimeslotInstances( + idService.EventTimeslot.Encode(t.Id), + t.Instances.Select(i => new EventInstance( + idService.EventInstance.Encode(i.Id), + i.GroupCode, + i.Players.Select(y => idService.User.Encode(y.Id)).ToArray() + )) + .ToArray() + )) + .ToArray(), + e.RegistrationDeadline, + e.StartedAt + }) + .FirstOrDefaultAsync(ct); + + if (eventInfo == null) + { + Logger.LogWarning(EndpointErrors.EventNotFound, eventId); + await this.SendErrorAsync(EndpointErrors.EventNotFound, req.EventId, ct); + return; + } + + if (eventInfo.RegistrationDeadline >= DateTimeOffset.Now) + { + Logger.LogWarning( + EndpointErrors.EventRegistrationNotElapsed, + eventId, + eventInfo.RegistrationDeadline + ); + await this.SendErrorAsync( + EndpointErrors.EventRegistrationNotElapsed, + req.EventId, + eventInfo.RegistrationDeadline, + ct + ); + return; + } + + await eventInstanceService.PersistEventInstancesAsync(req.Instances, ct); + + await SendOkAsync(ct); + + await realtimeEventsService.SendEventAsync( + new RealtimeEvent.EventInstancesChanged(idService.Event.Encode(eventId)), + ct + ); + + if (eventInfo.StartedAt != null) + { + var updatedPlayers = GetUpdatedPlayerIds(eventInfo.Instances, req.Instances); + await SendUpdateNotifications(updatedPlayers.ToArray(), eventId, ct); + } + } + + private IEnumerable GetUpdatedPlayerIds( + IEnumerable oldInstances, + IEnumerable newInstances + ) + { + var oldGroups = oldInstances + .SelectMany(x => + x.Instances.Select(y => new { Group = (x.TimeslotId, y.GroupCode), y.PlayerIds }) + ) + .ToArray(); + var newGroups = newInstances + .SelectMany(x => + x.Instances.Select(y => new { Group = (x.TimeslotId, y.GroupCode), y.PlayerIds }) + ) + .ToArray(); + return oldGroups + .SelectMany(x => + { + var existing = newGroups.FirstOrDefault(y => y.Group == x.Group); + return existing != null + ? x + .PlayerIds.Except(existing.PlayerIds) + .Union(existing.PlayerIds.Except(x.PlayerIds)) + : x.PlayerIds; + }) + .Union( + newGroups.SelectMany(x => + oldGroups.FirstOrDefault(y => y.Group == x.Group) == null + ? x.PlayerIds + : Enumerable.Empty() + ) + ) + .Distinct() + .Select(x => idService.User.DecodeSingle(x)); + } + + private async Task SendUpdateNotifications( + ICollection playerIds, + long eventId, + CancellationToken ct + ) + { + var notifications = await databaseContext + .Users.Where(u => + playerIds.Contains(u.Id) + && ( + u.Settings == null + || u.Settings.EnableNotifications && u.Settings.NotifyOnEventUpdated + ) + ) + .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.EventInstanceUpdated( + idService.Event.Encode(eventId), + u.EventInstances.Where(i => i.EventTimeslot.EventId == eventId) + .Select(i => new NotificationTimeslotInfo( + i.EventTimeslot.Time, + i.GroupCode, + i.EventTimeslot.Map!.Name, + i.Players.Count + )) + .ToArray() + ) + }) + .ToArrayAsync(ct); + foreach (var notification in notifications) + await webPushService.SendAsync( + notification.Subscriptions, + notification.NotificationData, + ct + ); + } +}