diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..cb2fcbe --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,14 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "C#: Host Debug", + "type": "dotnet", + "request": "launch", + "projectPath": "${workspaceFolder}/src/server/host/MinigolfFriday.Host.csproj" + } + ] +} diff --git a/src/client/angular.json b/src/client/angular.json index a120df7..f4d1633 100644 --- a/src/client/angular.json +++ b/src/client/angular.json @@ -52,14 +52,24 @@ "inject": false } ], - "scripts": [] + "scripts": [], + "externalDependencies": ["http", "https", "url", "util", "net"], + "allowedCommonJsDependencies": [ + "copy-to-clipboard", + "tough-cookie", + "node-fetch", + "fetch-cookie", + "abort-controller", + "ws", + "eventsource" + ] }, "configurations": { "production": { "budgets": [ { "type": "initial", - "maximumWarning": "600kb", + "maximumWarning": "800kb", "maximumError": "1mb" }, { diff --git a/src/client/package.json b/src/client/package.json index 23593a6..f0d4ab9 100644 --- a/src/client/package.json +++ b/src/client/package.json @@ -31,6 +31,7 @@ "@angular/pwa": "^18.0.3", "@angular/router": "^18.0.2", "@angular/service-worker": "^18.0.2", + "@microsoft/signalr": "^8.0.0", "@ngneers/easy-ngrx-distinct-selector": "^0.1.1", "@ngrx/effects": "18.0.0-rc.0", "@ngrx/entity": "18.0.0-rc.0", diff --git a/src/client/pnpm-lock.yaml b/src/client/pnpm-lock.yaml index 7fd8f3e..c45f278 100644 --- a/src/client/pnpm-lock.yaml +++ b/src/client/pnpm-lock.yaml @@ -38,6 +38,9 @@ importers: '@angular/service-worker': specifier: ^18.0.2 version: 18.0.2(@angular/common@18.0.2(@angular/core@18.0.2(rxjs@7.8.1)(zone.js@0.14.7))(rxjs@7.8.1))(@angular/core@18.0.2(rxjs@7.8.1)(zone.js@0.14.7)) + '@microsoft/signalr': + specifier: ^8.0.0 + version: 8.0.0(encoding@0.1.13) '@ngneers/easy-ngrx-distinct-selector': specifier: ^0.1.1 version: 0.1.1(@ngrx/store@18.0.0-rc.0(@angular/core@18.0.2(rxjs@7.8.1)(zone.js@0.14.7))(rxjs@7.8.1)) @@ -1795,6 +1798,9 @@ packages: cpu: [x64] os: [win32] + '@microsoft/signalr@8.0.0': + resolution: {integrity: sha512-K/wS/VmzRWePCGqGh8MU8OWbS1Zvu7DG7LSJS62fBB8rJUXwwj4axQtqrAAwKGUZHQF6CuteuQR9xMsVpM2JNA==} + '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3': resolution: {integrity: sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==} cpu: [arm64] @@ -2516,6 +2522,10 @@ packages: resolution: {integrity: sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + abort-controller@3.0.0: + resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} + engines: {node: '>=6.5'} + accepts@1.3.8: resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} engines: {node: '>= 0.6'} @@ -3522,6 +3532,10 @@ packages: resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} engines: {node: '>= 0.6'} + event-target-shim@5.0.1: + resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} + engines: {node: '>=6'} + eventemitter3@4.0.7: resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==} @@ -3529,6 +3543,10 @@ packages: resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} engines: {node: '>=0.8.x'} + eventsource@2.0.2: + resolution: {integrity: sha512-IzUmBGPR3+oUG9dUeXynyNmf91/3zUSJg1lCktzKw47OXuhco54U3r9B7O4XX+Rb1Itm9OZ2b0RkTs10bICOxA==} + engines: {node: '>=12.0.0'} + execa@5.1.1: resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==} engines: {node: '>=10'} @@ -3575,6 +3593,9 @@ packages: fb-watchman@2.0.2: resolution: {integrity: sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==} + fetch-cookie@2.2.0: + resolution: {integrity: sha512-h9AgfjURuCgA2+2ISl8GbavpUdR+WGAM2McW/ovn4tVccegp8ZqCKWSBR8uRdM8dDNlx5WdKRWxBYUwteLDCNQ==} + figures@3.2.0: resolution: {integrity: sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==} engines: {node: '>=8'} @@ -5494,6 +5515,9 @@ packages: resolution: {integrity: sha512-XGuRDNjXUijsUL0vl6nSD7cwURuzEgglbOaFuZM9g3kwDXOWVTck0jLzjPzGD+TazWbboZYu52/9/XPdUgne9g==} engines: {node: '>= 0.8.0'} + set-cookie-parser@2.6.0: + resolution: {integrity: sha512-RVnVQxTXuerk653XfuliOxBP81Sf0+qfQE73LIYKcyMYHG94AuH0kgrQpRDuTZnSmjpysHmzxJXKNfa6PjFhyQ==} + set-function-length@1.2.0: resolution: {integrity: sha512-4DBHDoyHlM1IRPGYcoxexgh67y4ueR53FKV1yyxwFMY7aCqcN/38M1+SwZ/qJQ8iLv7+ck385ot4CcisOAPT9w==} engines: {node: '>= 0.4'} @@ -6205,6 +6229,18 @@ packages: resolution: {integrity: sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==} engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} + ws@7.5.10: + resolution: {integrity: sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==} + engines: {node: '>=8.3.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: ^5.0.2 + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + ws@8.16.0: resolution: {integrity: sha512-HS0c//TP7Ina87TfiPUz1rQzMhHrl/SG2guqRcTOIUYD2q8uhUdNHZYJUaQ8aTGPzCh+c6oawMKW35nFl1dxyQ==} engines: {node: '>=10.0.0'} @@ -8179,6 +8215,18 @@ snapshots: '@lmdb/lmdb-win32-x64@3.0.8': optional: true + '@microsoft/signalr@8.0.0(encoding@0.1.13)': + dependencies: + abort-controller: 3.0.0 + eventsource: 2.0.2 + fetch-cookie: 2.2.0 + node-fetch: 2.7.0(encoding@0.1.13) + ws: 7.5.10 + transitivePeerDependencies: + - bufferutil + - encoding + - utf-8-validate + '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3': optional: true @@ -8928,6 +8976,10 @@ snapshots: abbrev@2.0.0: {} + abort-controller@3.0.0: + dependencies: + event-target-shim: 5.0.1 + accepts@1.3.8: dependencies: mime-types: 2.1.35 @@ -10085,10 +10137,14 @@ snapshots: etag@1.8.1: {} + event-target-shim@5.0.1: {} + eventemitter3@4.0.7: {} events@3.3.0: {} + eventsource@2.0.2: {} + execa@5.1.1: dependencies: cross-spawn: 7.0.3 @@ -10181,6 +10237,11 @@ snapshots: dependencies: bser: 2.1.1 + fetch-cookie@2.2.0: + dependencies: + set-cookie-parser: 2.6.0 + tough-cookie: 4.1.3 + figures@3.2.0: dependencies: escape-string-regexp: 1.0.5 @@ -12388,6 +12449,8 @@ snapshots: transitivePeerDependencies: - supports-color + set-cookie-parser@2.6.0: {} + set-function-length@1.2.0: dependencies: define-data-property: 1.1.1 @@ -13187,6 +13250,8 @@ snapshots: imurmurhash: 0.1.4 signal-exit: 3.0.7 + ws@7.5.10: {} + ws@8.16.0: {} xml-name-validator@4.0.0: {} diff --git a/src/client/src/app/+state/action-state.ts b/src/client/src/app/+state/action-state.ts index c5c7c7e..7b9f1a6 100644 --- a/src/client/src/app/+state/action-state.ts +++ b/src/client/src/app/+state/action-state.ts @@ -103,7 +103,10 @@ export function handleHttpAction< >( actionStateName: TActionStateName, action: TAction, - startCondition: (state: TInferredState, props: TProps) => boolean = () => true + options?: { + condition?: (state: TInferredState, props: TProps) => boolean; + startCondition?: (state: TInferredState, props: TProps) => boolean; + } ): ReducerTypes< TInferredState, [TAction, TAction['starting'], TAction['success'], TAction['error']] @@ -114,11 +117,15 @@ export function handleHttpAction< action.success, action.error, produce((draft, props) => { + if (options?.condition && !options.condition(draft as TInferredState, props.props)) { + return; + } + const actionStates = (draft as TState).actionStates; if (props.type === action.type) { if ( !isActionBusy(actionStates[actionStateName as string]) && - startCondition(draft as TInferredState, props.props) + (!options?.startCondition || options.startCondition(draft as TInferredState, props.props)) ) { actionStates[actionStateName as string] = startingActionState; } @@ -135,14 +142,20 @@ export function handleHttpAction< export function onHttpAction>( action: T, - actionStateSelector: Selector + actionStateSelector?: Selector, + disableFilterCondition?: (p: ReturnType) => boolean ) { - return inject(Actions).pipe( - ofType(action), - withLatestFrom(inject(Store).select(actionStateSelector)), - filter(([, actionState]) => actionState.state === 'starting'), - map(([props]) => props) - ); + if (actionStateSelector) { + return inject(Actions).pipe( + ofType(action), + withLatestFrom(inject(Store).select(actionStateSelector)), + filter( + ([p, actionState]) => !!disableFilterCondition?.(p) || actionState.state === 'starting' + ), + map(([props]) => props) + ); + } + return inject(Actions).pipe(ofType(action)); } export function mapToHttpAction< diff --git a/src/client/src/app/+state/events/actions/add-event-timeslot.action.ts b/src/client/src/app/+state/events/actions/add-event-timeslot.action.ts index acb3d1e..c78d596 100644 --- a/src/client/src/app/+state/events/actions/add-event-timeslot.action.ts +++ b/src/client/src/app/+state/events/actions/add-event-timeslot.action.ts @@ -35,7 +35,9 @@ export const addEventTimeslotReducers: Reducers = [ state ); }), - handleHttpAction('addTimeslot', addEventTimeslotAction, (s, p) => !!s.entities[p.eventId]), + handleHttpAction('addTimeslot', addEventTimeslotAction, { + startCondition: (s, p) => !!s.entities[p.eventId], + }), ]; export const addEventTimeslotEffects: Effects = { diff --git a/src/client/src/app/+state/events/actions/add-player-to-preconfig.action.ts b/src/client/src/app/+state/events/actions/add-player-to-preconfig.action.ts index ae38257..6b56d79 100644 --- a/src/client/src/app/+state/events/actions/add-player-to-preconfig.action.ts +++ b/src/client/src/app/+state/events/actions/add-player-to-preconfig.action.ts @@ -34,14 +34,12 @@ export const addPlayerToEventPreconfigurationReducers: Reducers + handleHttpAction('addPlayerToPreconfig', addPlayerToEventPreconfigurationAction, { + startCondition: (s, p) => s.entities[p.eventId]?.timeslots.some( x => x.id === p.timeslotId && x.preconfigurations.some(y => y.id === p.preconfigId) - ) === true - ), + ) === true, + }), ]; export const addPlayerToEventPreconfigurationEffects: Effects = { diff --git a/src/client/src/app/+state/events/actions/event-timeslot-registration-changed.action.ts b/src/client/src/app/+state/events/actions/event-timeslot-registration-changed.action.ts new file mode 100644 index 0000000..2f71488 --- /dev/null +++ b/src/client/src/app/+state/events/actions/event-timeslot-registration-changed.action.ts @@ -0,0 +1,34 @@ +import { createAction, on, props } from '@ngrx/store'; +import { produce } from 'immer'; + +import { PlayerEventTimeslotRegistrationChanged } from '../../../models/realtime-events'; +import { Reducers } from '../../utils'; +import { EVENTS_ACTION_SCOPE } from '../consts'; +import { eventEntityAdapter, EventsFeatureState } from '../events.state'; + +export const eventTimeslotRegistrationChangedAction = createAction( + `[${EVENTS_ACTION_SCOPE}] Event Timeslot Registration Changed`, + props() +); + +export const eventTimeslotRegistrationChangedReducers: Reducers = [ + on(eventTimeslotRegistrationChangedAction, (state, event) => + eventEntityAdapter.mapOne( + { + id: event.eventId, + map: produce(draft => { + const timeslot = draft.timeslots.find(t => t.id === event.eventTimeslotId); + if (!timeslot) return; + if (event.isRegistered) { + if (!timeslot.playerIds.includes(event.userId)) { + timeslot.playerIds.push(event.userId); + } + } else { + timeslot.playerIds = timeslot.playerIds.filter(p => p !== event.userId); + } + }), + }, + state + ) + ), +]; diff --git a/src/client/src/app/+state/events/actions/load-event.action.ts b/src/client/src/app/+state/events/actions/load-event.action.ts index 0c54092..9d9fdd3 100644 --- a/src/client/src/app/+state/events/actions/load-event.action.ts +++ b/src/client/src/app/+state/events/actions/load-event.action.ts @@ -12,25 +12,24 @@ import { EVENTS_ACTION_SCOPE } from '../consts'; import { selectEventsActionState } from '../events.selectors'; import { EventsFeatureState, eventEntityAdapter } from '../events.state'; -export const loadEventAction = createHttpAction<{ eventId: string; reload?: boolean }, Event>()( - EVENTS_ACTION_SCOPE, - 'Load Event' -); +export const loadEventAction = createHttpAction< + { eventId: string; reload?: boolean; silent?: boolean }, + Event +>()(EVENTS_ACTION_SCOPE, 'Load Event'); export const loadEventReducers: Reducers = [ on(loadEventAction.success, (state, { response }) => eventEntityAdapter.upsertOne(response, state) ), - handleHttpAction( - 'loadOne', - loadEventAction, - (s, p) => !s.entities[p.eventId] || p.reload === true - ), + handleHttpAction('loadOne', loadEventAction, { + condition: (s, p) => p.silent !== true, + startCondition: (s, p) => !s.entities[p.eventId] || p.reload === true, + }), ]; export const loadEventEffects: Effects = { loadEvent$: createFunctionalEffect.dispatching((api = inject(EventAdministrationService)) => - onHttpAction(loadEventAction, selectEventsActionState('loadOne')).pipe( + onHttpAction(loadEventAction, selectEventsActionState('loadOne'), p => !!p.props.silent).pipe( switchMap(({ props }) => toHttpAction(getEvent(api, props), loadEventAction, props)) ) ), diff --git a/src/client/src/app/+state/events/actions/load-events.action.ts b/src/client/src/app/+state/events/actions/load-events.action.ts index d6a053a..c7e989e 100644 --- a/src/client/src/app/+state/events/actions/load-events.action.ts +++ b/src/client/src/app/+state/events/actions/load-events.action.ts @@ -15,10 +15,10 @@ import { selectEventsActionState, selectEventsContinuationToken } from '../event import { EventsFeatureState, eventEntityAdapter } from '../events.state'; type _Response = { events: Event[]; continuationToken: string | null }; -export const loadEventsAction = createHttpAction<{ reload?: boolean }, _Response>()( - EVENTS_ACTION_SCOPE, - 'Load Events' -); +export const loadEventsAction = createHttpAction< + { reload?: boolean; silent?: boolean }, + _Response +>()(EVENTS_ACTION_SCOPE, 'Load Events'); export const loadEventsReducers: Reducers = [ on(loadEventsAction.success, (state, { props, response }) => @@ -29,13 +29,13 @@ export const loadEventsReducers: Reducers = [ }) ) ), - handleHttpAction('load', loadEventsAction), + handleHttpAction('load', loadEventsAction, { condition: (s, p) => !p.silent }), ]; export const loadEventsEffects: Effects = { loadEvents$: createFunctionalEffect.dispatching( (store = inject(Store), api = inject(EventAdministrationService)) => - onHttpAction(loadEventsAction, selectEventsActionState('load')).pipe( + onHttpAction(loadEventsAction, selectEventsActionState('load'), p => !!p.props.silent).pipe( withLatestFrom(store.select(selectEventsContinuationToken)), switchMap(([{ props }, continuationToken]) => toHttpAction(getEvents(api, props, continuationToken), loadEventsAction, props) diff --git a/src/client/src/app/+state/events/actions/reset-events-action-state.action.ts b/src/client/src/app/+state/events/actions/reset-events-action-state.action.ts new file mode 100644 index 0000000..f8f11e6 --- /dev/null +++ b/src/client/src/app/+state/events/actions/reset-events-action-state.action.ts @@ -0,0 +1,21 @@ +import { createAction, on, props } from '@ngrx/store'; +import { produce } from 'immer'; + +import { initialActionState } from '../../action-state'; +import { Reducers } from '../../utils'; +import { EVENTS_ACTION_SCOPE } from '../consts'; +import { EventsFeatureState } from '../events.state'; + +export const resetEventsActionStateAction = createAction( + `[${EVENTS_ACTION_SCOPE}] Reset Action State`, + props<{ scope: keyof EventsFeatureState['actionStates'] }>() +); + +export const resetEventsActionStateReducers: Reducers = [ + on( + resetEventsActionStateAction, + produce((state, { scope }) => { + state.actionStates[scope] = initialActionState; + }) + ), +]; diff --git a/src/client/src/app/+state/events/events.actions.ts b/src/client/src/app/+state/events/events.actions.ts index 950f5ca..449e847 100644 --- a/src/client/src/app/+state/events/events.actions.ts +++ b/src/client/src/app/+state/events/events.actions.ts @@ -2,6 +2,7 @@ export { addEventPreconfigAction } from './actions/add-event-preconfig.action'; export { addEventTimeslotAction } from './actions/add-event-timeslot.action'; export { addEventAction } from './actions/add-event.action'; export { commitEventAction } from './actions/commit-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 { loadEventAction } from './actions/load-event.action'; @@ -10,5 +11,6 @@ export { removeEventAction } from './actions/remove-event.action'; export { removeEventPreconfigAction } from './actions/remove-event-preconfig.action'; export { removeEventTimeslotAction } from './actions/remove-event-timeslot.action'; export { removePlayerFromPreconfigAction } from './actions/remove-player-from-preconfig.action'; +export { resetEventsActionStateAction } from './actions/reset-events-action-state.action'; export { startEventAction } from './actions/start-event.action'; export { updateEventTimeslotAction } from './actions/update-event-timeslot.action'; diff --git a/src/client/src/app/+state/events/events.effects.ts b/src/client/src/app/+state/events/events.effects.ts index 579ccb9..0e4502b 100644 --- a/src/client/src/app/+state/events/events.effects.ts +++ b/src/client/src/app/+state/events/events.effects.ts @@ -1,17 +1,26 @@ +import { inject } from '@angular/core'; +import { mergeMap, of, EMPTY, map, merge } from 'rxjs'; + import { addEventPreconfigEffects } from './actions/add-event-preconfig.action'; import { addEventTimeslotEffects } from './actions/add-event-timeslot.action'; import { addEventEffects } from './actions/add-event.action'; import { addPlayerToEventPreconfigurationEffects } from './actions/add-player-to-preconfig.action'; import { buildEventInstancesEffects } from './actions/build-event-instances.action'; import { commitEventEffects } from './actions/commit-event.action'; -import { loadEventEffects } from './actions/load-event.action'; +import { loadEventAction, loadEventEffects } from './actions/load-event.action'; import { loadEventsEffects } from './actions/load-events.action'; import { removeEventPreconfigEffects } from './actions/remove-event-preconfig.action'; import { removeEventTimeslotEffects } from './actions/remove-event-timeslot.action'; -import { removeEventEffects } from './actions/remove-event.action'; +import { removeEventAction, removeEventEffects } from './actions/remove-event.action'; import { removePlayerFromPreconfigEffects } from './actions/remove-player-from-preconfig.action'; import { startEventEffects } from './actions/start-event.action'; import { updateEventTimeslotEffects } from './actions/update-event-timeslot.action'; +import { + eventTimeslotRegistrationChangedAction, + resetEventsActionStateAction, +} from './events.actions'; +import { RealtimeEventsService } from '../../services/realtime-events.service'; +import { createFunctionalEffect } from '../functional-effect'; import { Effects } from '../utils'; export const eventsFeatureEffects: Effects[] = [ @@ -29,4 +38,44 @@ export const eventsFeatureEffects: Effects[] = [ startEventEffects, commitEventEffects, updateEventTimeslotEffects, + { + eventUpdated$: createFunctionalEffect.dispatching((events = inject(RealtimeEventsService)) => + merge( + events.eventChanged, + events.eventTimeslotChanged, + events.eventPreconfigurationChanged, + events.eventInstancesChanged + ).pipe( + mergeMap(event => { + const eventId = event.eventId; + let changeType = 'changeType' in event ? event.changeType : 'updated'; + if (changeType === 'deleted' && 'eventTimeslotId' in event) { + changeType = 'updated'; + } + if (changeType === 'updated' || changeType === 'created') { + return of(loadEventAction({ eventId, reload: true, silent: true })); + } else if (changeType === 'deleted') { + return of(removeEventAction.success({ eventId }, undefined)); + } + return EMPTY; + }) + ) + ), + eventTimeslotRegistrationChanged$: createFunctionalEffect.dispatching(() => + inject(RealtimeEventsService).playerEventTimeslotRegistrationChanged.pipe( + map(event => eventTimeslotRegistrationChangedAction(event)) + ) + ), + + onServerReconnected$: createFunctionalEffect.dispatching(() => + inject(RealtimeEventsService).onReconnected$.pipe( + mergeMap(() => + of( + resetEventsActionStateAction({ scope: 'load' }), + resetEventsActionStateAction({ scope: 'loadOne' }) + ) + ) + ) + ), + }, ]; diff --git a/src/client/src/app/+state/events/events.reducer.ts b/src/client/src/app/+state/events/events.reducer.ts index 64a6919..fc116ed 100644 --- a/src/client/src/app/+state/events/events.reducer.ts +++ b/src/client/src/app/+state/events/events.reducer.ts @@ -6,12 +6,14 @@ import { addEventReducers } from './actions/add-event.action'; import { addPlayerToEventPreconfigurationReducers } from './actions/add-player-to-preconfig.action'; import { buildEventInstancesReducers } from './actions/build-event-instances.action'; import { commitEventReducers } from './actions/commit-event.action'; +import { eventTimeslotRegistrationChangedReducers } from './actions/event-timeslot-registration-changed.action'; import { loadEventReducers } from './actions/load-event.action'; import { loadEventsReducers } from './actions/load-events.action'; import { removeEventPreconfigReducers } from './actions/remove-event-preconfig.action'; import { removeEventTimeslotReducers } from './actions/remove-event-timeslot.action'; 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 { startEventReducers } from './actions/start-event.action'; import { updateEventTimeslotReducers } from './actions/update-event-timeslot.action'; import { EventsFeatureState, initialEventsFeatureState } from './events.state'; @@ -24,13 +26,15 @@ export const eventsReducer = createReducer( ...addEventReducers, ...addPlayerToEventPreconfigurationReducers, ...buildEventInstancesReducers, + ...commitEventReducers, + ...eventTimeslotRegistrationChangedReducers, ...loadEventReducers, ...loadEventsReducers, ...removeEventPreconfigReducers, ...removeEventTimeslotReducers, ...removeEventReducers, ...removePlayerFromPreconfigReducers, + ...resetEventsActionStateReducers, ...startEventReducers, - ...commitEventReducers, ...updateEventTimeslotReducers ); diff --git a/src/client/src/app/+state/events/events.utils.ts b/src/client/src/app/+state/events/events.utils.ts new file mode 100644 index 0000000..f5345e2 --- /dev/null +++ b/src/client/src/app/+state/events/events.utils.ts @@ -0,0 +1,39 @@ +import { DestroyRef, effect, Signal } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { Store } from '@ngrx/store'; +import { filter } from 'rxjs'; + +import { loadEventsAction, loadEventAction } from './events.actions'; +import { selectEventsActionState } from './events.selectors'; +import { injectEx, OptionalInjector } from '../../utils/angular.utils'; + +export function keepEventsLoaded(options?: OptionalInjector) { + const store = injectEx(Store, options); + store.dispatch(loadEventsAction({ reload: false })); + store + .select(selectEventsActionState('load')) + .pipe( + filter(x => x.state === 'none'), + takeUntilDestroyed(injectEx(DestroyRef, options)) + ) + .subscribe(() => store.dispatch(loadEventsAction({ reload: true, silent: true }))); +} + +export function keepEventLoaded(eventId: Signal, options?: OptionalInjector) { + const store = injectEx(Store, options); + + effect(() => store.dispatch(loadEventAction({ eventId: eventId(), reload: false })), { + ...options, + allowSignalWrites: true, + }); + + store + .select(selectEventsActionState('loadOne')) + .pipe( + filter(x => x.state === 'none'), + takeUntilDestroyed(injectEx(DestroyRef, options)) + ) + .subscribe(() => + store.dispatch(loadEventAction({ eventId: eventId(), reload: true, silent: true })) + ); +} diff --git a/src/client/src/app/+state/maps/actions/load-map.action.ts b/src/client/src/app/+state/maps/actions/load-map.action.ts new file mode 100644 index 0000000..6071e5f --- /dev/null +++ b/src/client/src/app/+state/maps/actions/load-map.action.ts @@ -0,0 +1,39 @@ +import { inject } from '@angular/core'; +import { on } from '@ngrx/store'; +import { mergeMap } from 'rxjs'; + +import { MapAdministrationService } from '../../../api/services'; +import { MinigolfMap } from '../../../models/parsed-models'; +import { assertBody } from '../../../utils/http.utils'; +import { createHttpAction, onHttpAction, toHttpAction } from '../../action-state'; +import { createFunctionalEffect } from '../../functional-effect'; +import { Effects, Reducers } from '../../utils'; +import { MAPS_ACTION_SCOPE } from '../consts'; +import { mapsEntityAdapter, MapsFeatureState } from '../maps.state'; + +export const loadMapAction = createHttpAction<{ mapId: string }, MinigolfMap>()( + MAPS_ACTION_SCOPE, + 'Load Map' +); + +export const loadMapReducers: Reducers = [ + on(loadMapAction.success, (state, { response }) => mapsEntityAdapter.upsertOne(response, state)), +]; + +export const loadMapEffects: Effects = { + loadMap$: createFunctionalEffect.dispatching((api = inject(MapAdministrationService)) => + onHttpAction(loadMapAction).pipe( + mergeMap(({ props }) => toHttpAction(getMap(api, props), loadMapAction, props)) + ) + ), +}; + +async function getMap( + api: MapAdministrationService, + props: ReturnType['props'] +) { + const response = await api.getMap({ mapId: props.mapId }); + return response.ok + ? loadMapAction.success(props, assertBody(response).map) + : loadMapAction.error(props, response); +} diff --git a/src/client/src/app/+state/maps/actions/load-maps.action.ts b/src/client/src/app/+state/maps/actions/load-maps.action.ts index 7f58870..5354db0 100644 --- a/src/client/src/app/+state/maps/actions/load-maps.action.ts +++ b/src/client/src/app/+state/maps/actions/load-maps.action.ts @@ -12,10 +12,10 @@ import { MAPS_ACTION_SCOPE } from '../consts'; import { selectMapsActionState } from '../maps.selectors'; import { mapsEntityAdapter, MapsFeatureState } from '../maps.state'; -export const loadMapsAction = createHttpAction<{ reload?: boolean }, MinigolfMap[]>()( - MAPS_ACTION_SCOPE, - 'Load Maps' -); +export const loadMapsAction = createHttpAction< + { reload?: boolean; silent?: boolean }, + MinigolfMap[] +>()(MAPS_ACTION_SCOPE, 'Load Maps'); export const loadMapsReducers: Reducers = [ on(loadMapsAction.success, (state, { props, response }) => @@ -24,12 +24,12 @@ export const loadMapsReducers: Reducers = [ props.reload ? mapsEntityAdapter.removeAll(state) : state ) ), - handleHttpAction('load', loadMapsAction), + handleHttpAction('load', loadMapsAction, { condition: (s, p) => !p.silent }), ]; export const loadMapsEffects: Effects = { loadMaps$: createFunctionalEffect.dispatching((api = inject(MapAdministrationService)) => - onHttpAction(loadMapsAction, selectMapsActionState('load')).pipe( + onHttpAction(loadMapsAction, selectMapsActionState('load'), p => !!p.props.silent).pipe( switchMap(({ props }) => toHttpAction(getMaps(api, props), loadMapsAction, props)) ) ), diff --git a/src/client/src/app/+state/maps/actions/reset-maps-action-state.action.ts b/src/client/src/app/+state/maps/actions/reset-maps-action-state.action.ts new file mode 100644 index 0000000..ae66d60 --- /dev/null +++ b/src/client/src/app/+state/maps/actions/reset-maps-action-state.action.ts @@ -0,0 +1,21 @@ +import { createAction, on, props } from '@ngrx/store'; +import { produce } from 'immer'; + +import { initialActionState } from '../../action-state'; +import { Reducers } from '../../utils'; +import { MAPS_ACTION_SCOPE } from '../consts'; +import { MapsFeatureState } from '../maps.state'; + +export const resetMapsActionStateAction = createAction( + `[${MAPS_ACTION_SCOPE}] Reset Action State`, + props<{ scope: keyof MapsFeatureState['actionStates'] }>() +); + +export const resetMapsActionStateReducers: Reducers = [ + on( + resetMapsActionStateAction, + produce((state, { scope }) => { + state.actionStates[scope] = initialActionState; + }) + ), +]; diff --git a/src/client/src/app/+state/maps/maps.actions.ts b/src/client/src/app/+state/maps/maps.actions.ts index f2ffb87..84d0ef2 100644 --- a/src/client/src/app/+state/maps/maps.actions.ts +++ b/src/client/src/app/+state/maps/maps.actions.ts @@ -1,4 +1,6 @@ export { addMapAction } from './actions/add-map.action'; +export { loadMapAction } from './actions/load-map.action'; export { loadMapsAction } from './actions/load-maps.action'; export { removeMapAction } from './actions/remove-map.action'; +export { resetMapsActionStateAction as resetMapActionStateAction } from './actions/reset-maps-action-state.action'; export { updateMapAction } from './actions/update-map.action'; diff --git a/src/client/src/app/+state/maps/maps.effects.ts b/src/client/src/app/+state/maps/maps.effects.ts index a78f854..590623f 100644 --- a/src/client/src/app/+state/maps/maps.effects.ts +++ b/src/client/src/app/+state/maps/maps.effects.ts @@ -1,12 +1,40 @@ +import { inject } from '@angular/core'; +import { mergeMap, of, EMPTY, map } from 'rxjs'; + import { addMapEffects } from './actions/add-map.action'; +import { loadMapAction, loadMapEffects } from './actions/load-map.action'; import { loadMapsEffects } from './actions/load-maps.action'; -import { removeMapEffects } from './actions/remove-map.action'; +import { removeMapAction, removeMapEffects } from './actions/remove-map.action'; import { updateMapEffects } from './actions/update-map.action'; +import { resetMapActionStateAction } from './maps.actions'; +import { RealtimeEventsService } from '../../services/realtime-events.service'; +import { createFunctionalEffect } from '../functional-effect'; import { Effects } from '../utils'; export const mapsFeatureEffects: Effects[] = [ addMapEffects, + loadMapEffects, loadMapsEffects, removeMapEffects, updateMapEffects, + { + mapUpdated$: createFunctionalEffect.dispatching(() => + inject(RealtimeEventsService).mapChanged.pipe( + mergeMap(({ mapId, changeType }) => { + if (changeType === 'updated' || changeType === 'created') { + return of(loadMapAction({ mapId })); + } else if (changeType === 'deleted') { + return of(removeMapAction.success({ mapId }, undefined)); + } + return EMPTY; + }) + ) + ), + + onServerReconnected$: createFunctionalEffect.dispatching(() => + inject(RealtimeEventsService).onReconnected$.pipe( + map(() => resetMapActionStateAction({ scope: 'load' })) + ) + ), + }, ]; diff --git a/src/client/src/app/+state/maps/maps.reducer.ts b/src/client/src/app/+state/maps/maps.reducer.ts index ebb8283..abc3ed9 100644 --- a/src/client/src/app/+state/maps/maps.reducer.ts +++ b/src/client/src/app/+state/maps/maps.reducer.ts @@ -1,8 +1,10 @@ import { createReducer } from '@ngrx/store'; import { addMapReducers } from './actions/add-map.action'; +import { loadMapReducers } from './actions/load-map.action'; import { loadMapsReducers } from './actions/load-maps.action'; import { removeMapReducers } from './actions/remove-map.action'; +import { resetMapsActionStateReducers } from './actions/reset-maps-action-state.action'; import { updateMapReducers } from './actions/update-map.action'; import { initialMapsFeatureState, MapsFeatureState } from './maps.state'; @@ -10,7 +12,9 @@ export const mapsReducer = createReducer( initialMapsFeatureState, ...addMapReducers, + ...loadMapReducers, ...loadMapsReducers, ...removeMapReducers, + ...resetMapsActionStateReducers, ...updateMapReducers ); diff --git a/src/client/src/app/+state/maps/maps.utils.ts b/src/client/src/app/+state/maps/maps.utils.ts new file mode 100644 index 0000000..e04fb8e --- /dev/null +++ b/src/client/src/app/+state/maps/maps.utils.ts @@ -0,0 +1,20 @@ +import { DestroyRef, Signal } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { Store } from '@ngrx/store'; +import { filter } from 'rxjs'; + +import { loadMapsAction } from './maps.actions'; +import { selectMapsActionState } from './maps.selectors'; +import { injectEx, OptionalInjector } from '../../utils/angular.utils'; + +export function keepMapsLoaded(options?: OptionalInjector & { enabled?: Signal }) { + const store = injectEx(Store, options); + store.dispatch(loadMapsAction({ reload: false })); + store + .select(selectMapsActionState('load')) + .pipe( + filter(x => x.state === 'none'), + takeUntilDestroyed(injectEx(DestroyRef, options)) + ) + .subscribe(() => store.dispatch(loadMapsAction({ reload: true, silent: true }))); +} diff --git a/src/client/src/app/+state/player-events/actions/load-player-event.action.ts b/src/client/src/app/+state/player-events/actions/load-player-event.action.ts index f17fd87..86f1f19 100644 --- a/src/client/src/app/+state/player-events/actions/load-player-event.action.ts +++ b/src/client/src/app/+state/player-events/actions/load-player-event.action.ts @@ -1,6 +1,6 @@ import { inject } from '@angular/core'; import { on } from '@ngrx/store'; -import { switchMap } from 'rxjs'; +import { mergeMap } from 'rxjs'; import { EventsService } from '../../../api/services'; import { parsePlayerEvent, PlayerEvent } from '../../../models/parsed-models'; @@ -13,7 +13,7 @@ import { selectPlayerEventsActionState } from '../player-events.selectors'; import { PlayerEventsFeatureState, playerEventEntityAdapter } from '../player-events.state'; export const loadPlayerEventAction = createHttpAction< - { eventId: string; reload?: boolean }, + { eventId: string; reload?: boolean; silent?: boolean }, PlayerEvent >()(PLAYER_EVENTS_ACTION_SCOPE, 'Load Player Event'); @@ -21,17 +21,20 @@ export const loadPlayerEventReducers: Reducers = [ on(loadPlayerEventAction.success, (state, { response }) => playerEventEntityAdapter.upsertOne(response, state) ), - handleHttpAction( - 'loadOne', - loadPlayerEventAction, - (s, p) => !s.entities[p.eventId] || p.reload === true - ), + handleHttpAction('loadOne', loadPlayerEventAction, { + condition: (s, p) => p.silent !== true, + startCondition: (s, p) => !s.entities[p.eventId] || p.reload === true, + }), ]; export const loadPlayerEventEffects: Effects = { loadPlayerEvent$: createFunctionalEffect.dispatching((api = inject(EventsService)) => - onHttpAction(loadPlayerEventAction, selectPlayerEventsActionState('loadOne')).pipe( - switchMap(({ props }) => + onHttpAction( + loadPlayerEventAction, + selectPlayerEventsActionState('loadOne'), + p => !!p.props.silent + ).pipe( + mergeMap(({ props }) => toHttpAction(getPlayerEvent(api, props), loadPlayerEventAction, props) ) ) diff --git a/src/client/src/app/+state/player-events/actions/load-player-events.action.ts b/src/client/src/app/+state/player-events/actions/load-player-events.action.ts index bf77a8b..65cc2fb 100644 --- a/src/client/src/app/+state/player-events/actions/load-player-events.action.ts +++ b/src/client/src/app/+state/player-events/actions/load-player-events.action.ts @@ -18,10 +18,10 @@ import { import { PlayerEventsFeatureState, playerEventEntityAdapter } from '../player-events.state'; type _Response = { events: PlayerEvent[]; continuationToken: string | null }; -export const loadPlayerEventsAction = createHttpAction<{ reload?: boolean }, _Response>()( - PLAYER_EVENTS_ACTION_SCOPE, - 'Load Player Events' -); +export const loadPlayerEventsAction = createHttpAction< + { reload?: boolean; silent?: boolean }, + _Response +>()(PLAYER_EVENTS_ACTION_SCOPE, 'Load Player Events'); export const loadPlayerEventsReducers: Reducers = [ on(loadPlayerEventsAction.success, (state, { props, response }) => @@ -32,13 +32,17 @@ export const loadPlayerEventsReducers: Reducers = [ }) ) ), - handleHttpAction('load', loadPlayerEventsAction), + handleHttpAction('load', loadPlayerEventsAction, { condition: (s, p) => !p.silent }), ]; export const loadPlayerEventsEffects: Effects = { loadPlayerEvents$: createFunctionalEffect.dispatching( (store = inject(Store), api = inject(EventsService)) => - onHttpAction(loadPlayerEventsAction, selectPlayerEventsActionState('load')).pipe( + onHttpAction( + loadPlayerEventsAction, + selectPlayerEventsActionState('load'), + p => !!p.props.silent + ).pipe( withLatestFrom(store.select(selectPlayerEventsContinuationToken)), switchMap(([{ props }, continuationToken]) => toHttpAction( diff --git a/src/client/src/app/+state/player-events/actions/player-event-removed.action.ts b/src/client/src/app/+state/player-events/actions/player-event-removed.action.ts new file mode 100644 index 0000000..4471893 --- /dev/null +++ b/src/client/src/app/+state/player-events/actions/player-event-removed.action.ts @@ -0,0 +1,16 @@ +import { createAction, on, props } from '@ngrx/store'; + +import { Reducers } from '../../utils'; +import { PLAYER_EVENTS_ACTION_SCOPE } from '../consts'; +import { playerEventEntityAdapter, PlayerEventsFeatureState } from '../player-events.state'; + +export const playerEventRemovedAction = createAction( + `[${PLAYER_EVENTS_ACTION_SCOPE}] Player Event Removed`, + props<{ eventId: string }>() +); + +export const playerEventRemovedReducers: Reducers = [ + on(playerEventRemovedAction, (state, { eventId }) => + playerEventEntityAdapter.removeOne(eventId, state) + ), +]; diff --git a/src/client/src/app/+state/player-events/actions/reset-player-events-action-state.action.ts b/src/client/src/app/+state/player-events/actions/reset-player-events-action-state.action.ts new file mode 100644 index 0000000..c3a36b3 --- /dev/null +++ b/src/client/src/app/+state/player-events/actions/reset-player-events-action-state.action.ts @@ -0,0 +1,21 @@ +import { createAction, on, props } from '@ngrx/store'; +import { produce } from 'immer'; + +import { initialActionState } from '../../action-state'; +import { Reducers } from '../../utils'; +import { PLAYER_EVENTS_ACTION_SCOPE } from '../consts'; +import { PlayerEventsFeatureState } from '../player-events.state'; + +export const resetPlayerEventsActionStateAction = createAction( + `[${PLAYER_EVENTS_ACTION_SCOPE}] Reset Action State`, + props<{ scope: keyof PlayerEventsFeatureState['actionStates'] }>() +); + +export const resetPlayerEventsActionStateReducers: Reducers = [ + on( + resetPlayerEventsActionStateAction, + produce((state, { scope }) => { + state.actionStates[scope] = initialActionState; + }) + ), +]; diff --git a/src/client/src/app/+state/player-events/player-events.actions.ts b/src/client/src/app/+state/player-events/player-events.actions.ts index 9f31ff8..bf6dbd4 100644 --- a/src/client/src/app/+state/player-events/player-events.actions.ts +++ b/src/client/src/app/+state/player-events/player-events.actions.ts @@ -1,3 +1,5 @@ export { loadPlayerEventAction } from './actions/load-player-event.action'; export { loadPlayerEventsAction } from './actions/load-player-events.action'; +export { playerEventRemovedAction } from './actions/player-event-removed.action'; +export { resetPlayerEventsActionStateAction } from './actions/reset-player-events-action-state.action'; export { updateEventRegistrationAction } from './actions/update-event-registration.action'; diff --git a/src/client/src/app/+state/player-events/player-events.effects.ts b/src/client/src/app/+state/player-events/player-events.effects.ts index 9a664ec..a437815 100644 --- a/src/client/src/app/+state/player-events/player-events.effects.ts +++ b/src/client/src/app/+state/player-events/player-events.effects.ts @@ -1,10 +1,47 @@ -import { loadPlayerEventEffects } from './actions/load-player-event.action'; +import { inject } from '@angular/core'; +import { EMPTY, merge, mergeMap, of } from 'rxjs'; + +import { loadPlayerEventAction, loadPlayerEventEffects } from './actions/load-player-event.action'; import { loadPlayerEventsEffects } from './actions/load-player-events.action'; import { updateEventRegistrationEffects } from './actions/update-event-registration.action'; +import { + playerEventRemovedAction, + resetPlayerEventsActionStateAction, +} from './player-events.actions'; +import { RealtimeEventsService } from '../../services/realtime-events.service'; +import { createFunctionalEffect } from '../functional-effect'; import { Effects } from '../utils'; export const PlayerEventsFeatureEffects: Effects[] = [ loadPlayerEventsEffects, loadPlayerEventEffects, updateEventRegistrationEffects, + { + playerEventUpdated$: createFunctionalEffect.dispatching( + (events = inject(RealtimeEventsService)) => + merge(events.playerEventChanged, events.playerEventRegistrationChanged).pipe( + mergeMap(event => { + const eventId = event.eventId; + const changeType = 'changeType' in event ? event.changeType : 'updated'; + if (changeType === 'updated' || changeType === 'created') { + return of(loadPlayerEventAction({ eventId, reload: true, silent: true })); + } else if (changeType === 'deleted') { + return of(playerEventRemovedAction({ eventId })); + } + return EMPTY; + }) + ) + ), + + onServerReconnected$: createFunctionalEffect.dispatching(() => + inject(RealtimeEventsService).onReconnected$.pipe( + mergeMap(() => + of( + resetPlayerEventsActionStateAction({ scope: 'load' }), + resetPlayerEventsActionStateAction({ scope: 'loadOne' }) + ) + ) + ) + ), + }, ]; diff --git a/src/client/src/app/+state/player-events/player-events.reducer.ts b/src/client/src/app/+state/player-events/player-events.reducer.ts index 7d296f0..24b457c 100644 --- a/src/client/src/app/+state/player-events/player-events.reducer.ts +++ b/src/client/src/app/+state/player-events/player-events.reducer.ts @@ -3,6 +3,8 @@ import { produce } from 'immer'; import { loadPlayerEventReducers } from './actions/load-player-event.action'; import { loadPlayerEventsReducers } from './actions/load-player-events.action'; +import { playerEventRemovedReducers } from './actions/player-event-removed.action'; +import { resetPlayerEventsActionStateReducers } from './actions/reset-player-events-action-state.action'; import { updateEventRegistrationReducers } from './actions/update-event-registration.action'; import { PlayerEventsFeatureState, @@ -23,6 +25,8 @@ export const playerEventsReducer = createReducer( ...loadPlayerEventsReducers, ...loadPlayerEventReducers, + ...playerEventRemovedReducers, + ...resetPlayerEventsActionStateReducers, ...updateEventRegistrationReducers, on(addEventAction.success, (state, { response }) => diff --git a/src/client/src/app/+state/player-events/player-events.utils.ts b/src/client/src/app/+state/player-events/player-events.utils.ts new file mode 100644 index 0000000..c095e61 --- /dev/null +++ b/src/client/src/app/+state/player-events/player-events.utils.ts @@ -0,0 +1,39 @@ +import { DestroyRef, effect, Signal } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { Store } from '@ngrx/store'; +import { filter } from 'rxjs'; + +import { loadPlayerEventAction, loadPlayerEventsAction } from './player-events.actions'; +import { selectPlayerEventsActionState } from './player-events.selectors'; +import { injectEx, OptionalInjector } from '../../utils/angular.utils'; + +export function keepPlayerEventsLoaded(options?: OptionalInjector) { + const store = injectEx(Store, options); + store.dispatch(loadPlayerEventsAction({ reload: false })); + store + .select(selectPlayerEventsActionState('load')) + .pipe( + filter(x => x.state === 'none'), + takeUntilDestroyed(injectEx(DestroyRef, options)) + ) + .subscribe(() => store.dispatch(loadPlayerEventsAction({ reload: true, silent: true }))); +} + +export function keepPlayerEventLoaded(eventId: Signal, options?: OptionalInjector) { + const store = injectEx(Store, options); + + effect(() => store.dispatch(loadPlayerEventAction({ eventId: eventId(), reload: false })), { + ...options, + allowSignalWrites: true, + }); + + store + .select(selectPlayerEventsActionState('loadOne')) + .pipe( + filter(x => x.state === 'none'), + takeUntilDestroyed(injectEx(DestroyRef, options)) + ) + .subscribe(() => + store.dispatch(loadPlayerEventAction({ eventId: eventId(), reload: true, silent: true })) + ); +} diff --git a/src/client/src/app/+state/user-settings/actions/load-user-settings.action.ts b/src/client/src/app/+state/user-settings/actions/load-user-settings.action.ts index c6c1389..e8d6ce2 100644 --- a/src/client/src/app/+state/user-settings/actions/load-user-settings.action.ts +++ b/src/client/src/app/+state/user-settings/actions/load-user-settings.action.ts @@ -10,13 +10,13 @@ import { createHttpAction, handleHttpAction, onHttpAction, toHttpAction } from ' import { createFunctionalEffect } from '../../functional-effect'; import { Effects, Reducers } from '../../utils'; import { USER_SETTINGS_ACTION_SCOPE } from '../consts'; -import { selectUserSettingsActionState } from '../users.selectors'; -import { UserSettingsFeatureState } from '../users.state'; +import { selectUserSettingsActionState } from '../user-settings.selectors'; +import { UserSettingsFeatureState } from '../user-settings.state'; -export const loadUserSettingsAction = createHttpAction<{ reload?: boolean }, UserSettings>()( - USER_SETTINGS_ACTION_SCOPE, - 'Load' -); +export const loadUserSettingsAction = createHttpAction< + { reload?: boolean; silent?: boolean }, + UserSettings +>()(USER_SETTINGS_ACTION_SCOPE, 'Load'); export const loadUserSettingsReducers: Reducers = [ on( @@ -25,12 +25,19 @@ export const loadUserSettingsReducers: Reducers = [ draft.settings = response; }) ), - handleHttpAction('load', loadUserSettingsAction, (s, p) => !s.settings || !!p.reload), + handleHttpAction('load', loadUserSettingsAction, { + condition: (s, p) => !p.silent, + startCondition: (s, p) => !s.settings || !!p.reload, + }), ]; export const loadUserSettingsEffects: Effects = { loadUserSettings$: createFunctionalEffect.dispatching((api = inject(UserSettingsService)) => - onHttpAction(loadUserSettingsAction, selectUserSettingsActionState('load')).pipe( + onHttpAction( + loadUserSettingsAction, + selectUserSettingsActionState('load'), + p => !!p.props.silent + ).pipe( switchMap(({ props }) => toHttpAction(loadUserSettings(api, props), loadUserSettingsAction, props) ) diff --git a/src/client/src/app/+state/user-settings/actions/reset-user-settings-action-state.action.ts b/src/client/src/app/+state/user-settings/actions/reset-user-settings-action-state.action.ts new file mode 100644 index 0000000..760ce1d --- /dev/null +++ b/src/client/src/app/+state/user-settings/actions/reset-user-settings-action-state.action.ts @@ -0,0 +1,21 @@ +import { createAction, on, props } from '@ngrx/store'; +import { produce } from 'immer'; + +import { initialActionState } from '../../action-state'; +import { Reducers } from '../../utils'; +import { USER_SETTINGS_ACTION_SCOPE } from '../consts'; +import { UserSettingsFeatureState } from '../user-settings.state'; + +export const resetUserSettingsActionStateAction = createAction( + `[${USER_SETTINGS_ACTION_SCOPE}] Reset Action State`, + props<{ scope: keyof UserSettingsFeatureState['actionStates'] }>() +); + +export const resetUserSettingsActionStateReducers: Reducers = [ + on( + resetUserSettingsActionStateAction, + produce((state, { scope }) => { + state.actionStates[scope] = initialActionState; + }) + ), +]; diff --git a/src/client/src/app/+state/user-settings/actions/update-user-settings.action.ts b/src/client/src/app/+state/user-settings/actions/update-user-settings.action.ts index 85f445d..17bb1d4 100644 --- a/src/client/src/app/+state/user-settings/actions/update-user-settings.action.ts +++ b/src/client/src/app/+state/user-settings/actions/update-user-settings.action.ts @@ -5,13 +5,14 @@ import { switchMap } from 'rxjs'; import { UserSettingsService } from '../../../api/services'; import { UserSettings } from '../../../models/parsed-models'; +import { RealtimeEventsService } from '../../../services/realtime-events.service'; import { isEmptyObject, removeUndefinedProperties } from '../../../utils/common.utils'; import { createHttpAction, handleHttpAction, onHttpAction, toHttpAction } from '../../action-state'; import { createFunctionalEffect } from '../../functional-effect'; import { Effects, Reducers } from '../../utils'; import { USER_SETTINGS_ACTION_SCOPE } from '../consts'; -import { selectUserSettingsActionState } from '../users.selectors'; -import { UserSettingsFeatureState } from '../users.state'; +import { selectUserSettingsActionState } from '../user-settings.selectors'; +import { UserSettingsFeatureState } from '../user-settings.state'; export const updateUserSettingsAction = createHttpAction>()( USER_SETTINGS_ACTION_SCOPE, @@ -27,20 +28,26 @@ export const updateUserSettingsReducers: Reducers = [ } }) ), - handleHttpAction( - 'update', - updateUserSettingsAction, - (s, p) => !!s.settings && !isEmptyObject(p, { ignoreUndefinedProperties: true }) - ), + handleHttpAction('update', updateUserSettingsAction, { + startCondition: (s, p) => + !!s.settings && !isEmptyObject(p, { ignoreUndefinedProperties: true }), + }), ]; export const updateUserSettingsEffects: Effects = { - updateUserSettings$: createFunctionalEffect.dispatching((api = inject(UserSettingsService)) => - onHttpAction(updateUserSettingsAction, selectUserSettingsActionState('update')).pipe( - switchMap(({ props }) => - toHttpAction(updateUserSettings(api, props), updateUserSettingsAction, props) + updateUserSettings$: createFunctionalEffect.dispatching( + (api = inject(UserSettingsService), events = inject(RealtimeEventsService)) => + onHttpAction(updateUserSettingsAction, selectUserSettingsActionState('update')).pipe( + switchMap(({ props }) => + toHttpAction( + updateUserSettings(api, props), + updateUserSettingsAction, + props + // , () => + // events.skipEvent('userSettingsChanged') + ) + ) ) - ) ), }; diff --git a/src/client/src/app/+state/user-settings/index.ts b/src/client/src/app/+state/user-settings/index.ts index 313af4d..1556e74 100644 --- a/src/client/src/app/+state/user-settings/index.ts +++ b/src/client/src/app/+state/user-settings/index.ts @@ -1,4 +1,4 @@ -export * from './users.actions'; -export { provideUserSettingsState } from './users.feature'; -export * from './users.selectors'; -export { UserSettingsFeatureState } from './users.state'; +export * from './user-settings.actions'; +export { provideUserSettingsState } from './user-settings.feature'; +export * from './user-settings.selectors'; +export { UserSettingsFeatureState } from './user-settings.state'; diff --git a/src/client/src/app/+state/user-settings/users.actions.ts b/src/client/src/app/+state/user-settings/user-settings.actions.ts similarity index 60% rename from src/client/src/app/+state/user-settings/users.actions.ts rename to src/client/src/app/+state/user-settings/user-settings.actions.ts index fda10ef..361b0cd 100644 --- a/src/client/src/app/+state/user-settings/users.actions.ts +++ b/src/client/src/app/+state/user-settings/user-settings.actions.ts @@ -1,2 +1,3 @@ export { loadUserSettingsAction } from './actions/load-user-settings.action'; +export { resetUserSettingsActionStateAction } from './actions/reset-user-settings-action-state.action'; export { updateUserSettingsAction } from './actions/update-user-settings.action'; diff --git a/src/client/src/app/+state/user-settings/user-settings.effects.ts b/src/client/src/app/+state/user-settings/user-settings.effects.ts new file mode 100644 index 0000000..281cbf2 --- /dev/null +++ b/src/client/src/app/+state/user-settings/user-settings.effects.ts @@ -0,0 +1,38 @@ +import { inject } from '@angular/core'; +import { Store } from '@ngrx/store'; +import { withLatestFrom, mergeMap, of, EMPTY, map } from 'rxjs'; + +import { + loadUserSettingsAction, + loadUserSettingsEffects, +} from './actions/load-user-settings.action'; +import { updateUserSettingsEffects } from './actions/update-user-settings.action'; +import { resetUserSettingsActionStateAction } from './user-settings.actions'; +import { selectUserSettings } from './user-settings.selectors'; +import { RealtimeEventsService } from '../../services/realtime-events.service'; +import { createFunctionalEffect } from '../functional-effect'; +import { Effects } from '../utils'; + +export const userSettingsFeatureEffects: Effects[] = [ + loadUserSettingsEffects, + updateUserSettingsEffects, + { + userSettingsUpdated$: createFunctionalEffect.dispatching((store = inject(Store)) => + inject(RealtimeEventsService).userSettingsChanged.pipe( + withLatestFrom(store.select(selectUserSettings)), + mergeMap(([_, settings]) => { + if (settings) { + return of(loadUserSettingsAction({ reload: true, silent: true })); + } + return EMPTY; + }) + ) + ), + + onServerReconnected$: createFunctionalEffect.dispatching(() => + inject(RealtimeEventsService).onReconnected$.pipe( + map(() => resetUserSettingsActionStateAction({ scope: 'load' })) + ) + ), + }, +]; diff --git a/src/client/src/app/+state/user-settings/users.feature.ts b/src/client/src/app/+state/user-settings/user-settings.feature.ts similarity index 76% rename from src/client/src/app/+state/user-settings/users.feature.ts rename to src/client/src/app/+state/user-settings/user-settings.feature.ts index 01d27ad..5967a50 100644 --- a/src/client/src/app/+state/user-settings/users.feature.ts +++ b/src/client/src/app/+state/user-settings/user-settings.feature.ts @@ -2,8 +2,8 @@ import { provideEffects } from '@ngrx/effects'; import { createFeature, provideState } from '@ngrx/store'; import { USER_SETTINGS_FEATURE_NAME } from './consts'; -import { userSettingsFeatureEffects } from './users.effects'; -import { userSettingsReducer } from './users.reducer'; +import { userSettingsFeatureEffects } from './user-settings.effects'; +import { userSettingsReducer } from './user-settings.reducer'; export const userSettingsFeature = createFeature({ name: USER_SETTINGS_FEATURE_NAME, diff --git a/src/client/src/app/+state/user-settings/users.reducer.ts b/src/client/src/app/+state/user-settings/user-settings.reducer.ts similarity index 71% rename from src/client/src/app/+state/user-settings/users.reducer.ts rename to src/client/src/app/+state/user-settings/user-settings.reducer.ts index f979518..31ede11 100644 --- a/src/client/src/app/+state/user-settings/users.reducer.ts +++ b/src/client/src/app/+state/user-settings/user-settings.reducer.ts @@ -1,12 +1,14 @@ import { createReducer } from '@ngrx/store'; import { loadUserSettingsReducers } from './actions/load-user-settings.action'; +import { resetUserSettingsActionStateReducers } from './actions/reset-user-settings-action-state.action'; import { updateUserSettingsReducers } from './actions/update-user-settings.action'; -import { UserSettingsFeatureState, initialUserSettingsFeatureState } from './users.state'; +import { UserSettingsFeatureState, initialUserSettingsFeatureState } from './user-settings.state'; export const userSettingsReducer = createReducer( initialUserSettingsFeatureState, ...loadUserSettingsReducers, + ...resetUserSettingsActionStateReducers, ...updateUserSettingsReducers ); diff --git a/src/client/src/app/+state/user-settings/users.selectors.ts b/src/client/src/app/+state/user-settings/user-settings.selectors.ts similarity index 92% rename from src/client/src/app/+state/user-settings/users.selectors.ts rename to src/client/src/app/+state/user-settings/user-settings.selectors.ts index 58a1b44..456b1e9 100644 --- a/src/client/src/app/+state/user-settings/users.selectors.ts +++ b/src/client/src/app/+state/user-settings/user-settings.selectors.ts @@ -2,7 +2,7 @@ import { createDistinctSelector } from '@ngneers/easy-ngrx-distinct-selector'; import { createFeatureSelector } from '@ngrx/store'; import { USER_SETTINGS_FEATURE_NAME } from './consts'; -import { UserSettingsFeatureState } from './users.state'; +import { UserSettingsFeatureState } from './user-settings.state'; export const selectUserSettingsFeature = createFeatureSelector( USER_SETTINGS_FEATURE_NAME diff --git a/src/client/src/app/+state/user-settings/users.state.ts b/src/client/src/app/+state/user-settings/user-settings.state.ts similarity index 100% rename from src/client/src/app/+state/user-settings/users.state.ts rename to src/client/src/app/+state/user-settings/user-settings.state.ts diff --git a/src/client/src/app/+state/user-settings/user-settings.utils.ts b/src/client/src/app/+state/user-settings/user-settings.utils.ts new file mode 100644 index 0000000..d7527e3 --- /dev/null +++ b/src/client/src/app/+state/user-settings/user-settings.utils.ts @@ -0,0 +1,20 @@ +import { DestroyRef } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { Store } from '@ngrx/store'; +import { filter, map } from 'rxjs'; + +import { loadUserSettingsAction } from './user-settings.actions'; +import { selectUserSettingsActionState } from './user-settings.selectors'; +import { injectEx, OptionalInjector } from '../../utils/angular.utils'; + +export function keepUserSettingsLoaded(options?: OptionalInjector) { + const store = injectEx(Store, options); + store + .select(selectUserSettingsActionState('load')) + .pipe( + filter(x => x.state === 'none'), + map((_, index) => index), + takeUntilDestroyed(injectEx(DestroyRef, options)) + ) + .subscribe(i => store.dispatch(loadUserSettingsAction({ reload: true, silent: i > 0 }))); +} diff --git a/src/client/src/app/+state/user-settings/users.effects.ts b/src/client/src/app/+state/user-settings/users.effects.ts deleted file mode 100644 index e88ceda..0000000 --- a/src/client/src/app/+state/user-settings/users.effects.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { loadUserSettingsEffects } from './actions/load-user-settings.action'; -import { updateUserSettingsEffects } from './actions/update-user-settings.action'; -import { Effects } from '../utils'; - -export const userSettingsFeatureEffects: Effects[] = [ - loadUserSettingsEffects, - updateUserSettingsEffects, -]; diff --git a/src/client/src/app/+state/users/actions/load-user.action.ts b/src/client/src/app/+state/users/actions/load-user.action.ts new file mode 100644 index 0000000..cfc1bb0 --- /dev/null +++ b/src/client/src/app/+state/users/actions/load-user.action.ts @@ -0,0 +1,39 @@ +import { inject } from '@angular/core'; +import { on } from '@ngrx/store'; +import { mergeMap } from 'rxjs'; + +import { UserAdministrationService } from '../../../api/services'; +import { User } from '../../../models/parsed-models'; +import { assertBody } from '../../../utils/http.utils'; +import { createHttpAction, onHttpAction, toHttpAction } from '../../action-state'; +import { createFunctionalEffect } from '../../functional-effect'; +import { Effects, Reducers } from '../../utils'; +import { USERS_ACTION_SCOPE } from '../consts'; +import { userEntityAdapter, UsersFeatureState } from '../users.state'; + +export const loadUserAction = createHttpAction<{ userId: string }, User>()( + USERS_ACTION_SCOPE, + 'Load User' +); + +export const loadUserReducers: Reducers = [ + on(loadUserAction.success, (state, { response }) => userEntityAdapter.upsertOne(response, state)), +]; + +export const loadUserEffects: Effects = { + loadUser$: createFunctionalEffect.dispatching((api = inject(UserAdministrationService)) => + onHttpAction(loadUserAction).pipe( + mergeMap(({ props }) => toHttpAction(getUser(api, props), loadUserAction, props)) + ) + ), +}; + +async function getUser( + api: UserAdministrationService, + props: ReturnType['props'] +) { + const response = await api.getUser({ userId: props.userId }); + return response.ok + ? loadUserAction.success(props, assertBody(response).user) + : loadUserAction.error(props, response); +} diff --git a/src/client/src/app/+state/users/actions/load-users.action.ts b/src/client/src/app/+state/users/actions/load-users.action.ts index cf6b561..6f1b385 100644 --- a/src/client/src/app/+state/users/actions/load-users.action.ts +++ b/src/client/src/app/+state/users/actions/load-users.action.ts @@ -12,7 +12,7 @@ import { USERS_ACTION_SCOPE } from '../consts'; import { selectUsersActionState } from '../users.selectors'; import { UsersFeatureState, userEntityAdapter } from '../users.state'; -export const loadUsersAction = createHttpAction<{ reload?: boolean }, User[]>()( +export const loadUsersAction = createHttpAction<{ reload?: boolean; silent?: boolean }, User[]>()( USERS_ACTION_SCOPE, 'Load Users' ); @@ -21,16 +21,15 @@ export const loadUsersReducers: Reducers = [ on(loadUsersAction.success, (state, { response }) => userEntityAdapter.upsertMany(response, state) ), - handleHttpAction( - 'load', - loadUsersAction, - (s, p) => s.actionStates.load.state === 'none' || p.reload === true - ), + handleHttpAction('load', loadUsersAction, { + condition: (s, p) => !p.silent, + startCondition: (s, p) => s.actionStates.load.state === 'none' || p.reload === true, + }), ]; export const loadUsersEffects: Effects = { loadUsers$: createFunctionalEffect.dispatching((api = inject(UserAdministrationService)) => - onHttpAction(loadUsersAction, selectUsersActionState('load')).pipe( + onHttpAction(loadUsersAction, selectUsersActionState('load'), p => !!p.props.silent).pipe( switchMap(({ props }) => toHttpAction(getUsers(api, props), loadUsersAction, props)) ) ), diff --git a/src/client/src/app/+state/users/actions/reset-users-action-state.action.ts b/src/client/src/app/+state/users/actions/reset-users-action-state.action.ts new file mode 100644 index 0000000..baf9e21 --- /dev/null +++ b/src/client/src/app/+state/users/actions/reset-users-action-state.action.ts @@ -0,0 +1,21 @@ +import { createAction, on, props } from '@ngrx/store'; +import { produce } from 'immer'; + +import { initialActionState } from '../../action-state'; +import { Reducers } from '../../utils'; +import { USERS_ACTION_SCOPE } from '../consts'; +import { UsersFeatureState } from '../users.state'; + +export const resetUsersActionStateAction = createAction( + `[${USERS_ACTION_SCOPE}] Reset Action State`, + props<{ scope: keyof UsersFeatureState['actionStates'] }>() +); + +export const resetUsersActionStateReducers: Reducers = [ + on( + resetUsersActionStateAction, + produce((state, { scope }) => { + state.actionStates[scope] = initialActionState; + }) + ), +]; diff --git a/src/client/src/app/+state/users/index.ts b/src/client/src/app/+state/users/index.ts index 073e185..f858c37 100644 --- a/src/client/src/app/+state/users/index.ts +++ b/src/client/src/app/+state/users/index.ts @@ -2,3 +2,4 @@ export * from './users.actions'; export { provideUsersState } from './users.feature'; export * from './users.selectors'; export { UsersFeatureState } from './users.state'; +export * from './users.utils'; diff --git a/src/client/src/app/+state/users/users.actions.ts b/src/client/src/app/+state/users/users.actions.ts index 8e19a2b..924f0e4 100644 --- a/src/client/src/app/+state/users/users.actions.ts +++ b/src/client/src/app/+state/users/users.actions.ts @@ -1,5 +1,7 @@ export { addUserAction } from './actions/add-user.action'; export { loadUserLoginTokenAction } from './actions/load-user-login-token.action'; +export { loadUserAction } from './actions/load-user.action'; export { loadUsersAction } from './actions/load-users.action'; export { removeUserAction } from './actions/remove-user.action'; +export { resetUsersActionStateAction } from './actions/reset-users-action-state.action'; export { updateUserAction } from './actions/update-user.action'; diff --git a/src/client/src/app/+state/users/users.effects.ts b/src/client/src/app/+state/users/users.effects.ts index f2a30d5..c965804 100644 --- a/src/client/src/app/+state/users/users.effects.ts +++ b/src/client/src/app/+state/users/users.effects.ts @@ -1,14 +1,42 @@ +import { inject } from '@angular/core'; +import { EMPTY, map, mergeMap, of } from 'rxjs'; + import { addUserEffects } from './actions/add-user.action'; import { loadUserLoginTokenEffects } from './actions/load-user-login-token.action'; +import { loadUserAction, loadUserEffects } from './actions/load-user.action'; import { loadUsersEffects } from './actions/load-users.action'; -import { removeUserEffects } from './actions/remove-user.action'; +import { removeUserAction, removeUserEffects } from './actions/remove-user.action'; import { updateUserEffects } from './actions/update-user.action'; +import { resetUsersActionStateAction } from './users.actions'; +import { RealtimeEventsService } from '../../services/realtime-events.service'; +import { createFunctionalEffect } from '../functional-effect'; import { Effects } from '../utils'; export const usersFeatureEffects: Effects[] = [ addUserEffects, loadUserLoginTokenEffects, + loadUserEffects, loadUsersEffects, removeUserEffects, updateUserEffects, + { + userUpdated$: createFunctionalEffect.dispatching(() => + inject(RealtimeEventsService).userChanged.pipe( + mergeMap(({ userId, changeType }) => { + if (changeType === 'updated' || changeType === 'created') { + return of(loadUserAction({ userId })); + } else if (changeType === 'deleted') { + return of(removeUserAction.success({ userId }, undefined)); + } + return EMPTY; + }) + ) + ), + + onServerReconnected$: createFunctionalEffect.dispatching(() => + inject(RealtimeEventsService).onReconnected$.pipe( + map(() => resetUsersActionStateAction({ scope: 'load' })) + ) + ), + }, ]; diff --git a/src/client/src/app/+state/users/users.reducer.ts b/src/client/src/app/+state/users/users.reducer.ts index df2bacc..2a62fd1 100644 --- a/src/client/src/app/+state/users/users.reducer.ts +++ b/src/client/src/app/+state/users/users.reducer.ts @@ -2,8 +2,10 @@ import { createReducer } from '@ngrx/store'; import { addUserReducers } from './actions/add-user.action'; import { loadUserLoginTokenReducers } from './actions/load-user-login-token.action'; +import { loadUserReducers } from './actions/load-user.action'; import { loadUsersReducers } from './actions/load-users.action'; import { removeUserReducers } from './actions/remove-user.action'; +import { resetUsersActionStateReducers } from './actions/reset-users-action-state.action'; import { updateUserReducers } from './actions/update-user.action'; import { UsersFeatureState, initialUsersFeatureState } from './users.state'; @@ -12,7 +14,9 @@ export const usersReducer = createReducer( ...addUserReducers, ...loadUserLoginTokenReducers, + ...loadUserReducers, ...loadUsersReducers, ...removeUserReducers, + ...resetUsersActionStateReducers, ...updateUserReducers ); diff --git a/src/client/src/app/+state/users/users.utils.ts b/src/client/src/app/+state/users/users.utils.ts new file mode 100644 index 0000000..8a23049 --- /dev/null +++ b/src/client/src/app/+state/users/users.utils.ts @@ -0,0 +1,20 @@ +import { DestroyRef } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { Store } from '@ngrx/store'; +import { filter } from 'rxjs'; + +import { loadUsersAction } from './users.actions'; +import { selectUsersActionState } from './users.selectors'; +import { injectEx, OptionalInjector } from '../../utils/angular.utils'; + +export function keepUsersLoaded(options?: OptionalInjector) { + const store = injectEx(Store, options); + store.dispatch(loadUsersAction({ reload: false })); + store + .select(selectUsersActionState('load')) + .pipe( + filter(x => x.state === 'none'), + takeUntilDestroyed(injectEx(DestroyRef, options)) + ) + .subscribe(() => store.dispatch(loadUsersAction({ reload: true, silent: true }))); +} diff --git a/src/client/src/app/app.config.ts b/src/client/src/app/app.config.ts index 29827b6..007f1ec 100644 --- a/src/client/src/app/app.config.ts +++ b/src/client/src/app/app.config.ts @@ -33,7 +33,7 @@ export const appConfig: ApplicationConfig = { router: routerReducer, }), provideRouterStore(), - provideStoreDevtools(), + provideStoreDevtools({ name: `Minigolf Friday (${Math.random().toString(16).substring(2)})` }), provideAppState(), provideUsersState(), provideMapsState(), diff --git a/src/client/src/app/components/app/menu/menu.component.ts b/src/client/src/app/components/app/menu/menu.component.ts index 08fdd32..8cd610b 100644 --- a/src/client/src/app/components/app/menu/menu.component.ts +++ b/src/client/src/app/components/app/menu/menu.component.ts @@ -9,11 +9,11 @@ import { ButtonModule } from 'primeng/button'; import { MenuModule } from 'primeng/menu'; import { MenubarModule } from 'primeng/menubar'; import { TooltipModule } from 'primeng/tooltip'; -import { fromEvent } from 'rxjs'; +import { filter, fromEvent, map, merge } from 'rxjs'; import { selectAppTitle } from '../../../+state/app'; import { AuthService } from '../../../services/auth.service'; -import { ThemeService } from '../../../services/theme.service'; +import { RealtimeEventsService } from '../../../services/realtime-events.service'; import { TranslateService, TranslationKey } from '../../../services/translate.service'; import { chainSignals } from '../../../utils/signal.utils'; @@ -35,11 +35,9 @@ import { chainSignals } from '../../../utils/signal.utils'; export class MenuComponent { private readonly _store = inject(Store); private readonly _translateService = inject(TranslateService); - private readonly _themeService = inject(ThemeService); private readonly _authService = inject(AuthService); private readonly _swUpdate = inject(SwUpdate); - - private readonly _versionInfo = toSignal(this._swUpdate.versionUpdates); + private readonly _realtimeEventsService = inject(RealtimeEventsService); protected readonly translations = this._translateService.translations; protected readonly title = chainSignals(this._store.selectSignal(selectAppTitle), title => @@ -51,8 +49,12 @@ export class MenuComponent { protected readonly isAdmin = computed( () => this._authService.user()?.roles.includes('admin') ?? false ); - protected readonly newVersionAvailable = computed( - () => this._versionInfo()?.type === 'VERSION_READY' + protected readonly newVersionAvailable = toSignal( + this._swUpdate.versionUpdates.pipe( + filter(x => x.type === 'VERSION_READY'), + map(() => true) + ), + { initialValue: false } ); protected readonly menuItems = computed(() => [ @@ -97,14 +99,16 @@ export class MenuComponent { ]); constructor() { - fromEvent(document, 'visibilitychange') - .pipe(takeUntilDestroyed()) - .subscribe(() => { - if (!document.hidden) { - console.info('Checking for updates...'); - this._swUpdate.checkForUpdate().then(x => console.info('Update check result:', x)); - } - }); + if (this._swUpdate.isEnabled) { + merge(fromEvent(document, 'visibilitychange'), this._realtimeEventsService.onReconnected$) + .pipe(takeUntilDestroyed()) + .subscribe(() => { + if (!document.hidden) { + console.info('Checking for updates...'); + this._swUpdate.checkForUpdate().then(x => console.info('Update check result:', x)); + } + }); + } } protected updateApp() { diff --git a/src/client/src/app/components/events/event-details/event-details.component.ts b/src/client/src/app/components/events/event-details/event-details.component.ts index e5c19fc..f4be5f5 100644 --- a/src/client/src/app/components/events/event-details/event-details.component.ts +++ b/src/client/src/app/components/events/event-details/event-details.component.ts @@ -1,5 +1,5 @@ import { CommonModule, formatDate } from '@angular/common'; -import { ChangeDetectionStrategy, Component, computed, effect, inject } from '@angular/core'; +import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core'; import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop'; import { ActivatedRoute, Router, RouterLink } from '@angular/router'; import { Actions, ofType } from '@ngrx/effects'; @@ -16,15 +16,16 @@ import { filter, map, timer } from 'rxjs'; import { hasActionFailed, isActionBusy } from '../../../+state/action-state'; import { buildEventInstancesAction, - loadEventAction, removeEventAction, selectEvent, selectEventsActionState, startEventAction, commitEventAction, } from '../../../+state/events'; -import { loadMapsAction, mapSelectors } from '../../../+state/maps'; -import { loadUsersAction, userSelectors } from '../../../+state/users'; +import { keepEventLoaded } from '../../../+state/events/events.utils'; +import { mapSelectors } from '../../../+state/maps'; +import { keepMapsLoaded } from '../../../+state/maps/maps.utils'; +import { keepUsersLoaded, userSelectors } from '../../../+state/users'; import { InterpolatePipe, interpolate } from '../../../directives/interpolate.pipe'; import { TranslateService } from '../../../services/translate.service'; import { ifTruthy } from '../../../utils/common.utils'; @@ -109,12 +110,9 @@ export class EventDetailsComponent { ); constructor() { - this._store.dispatch(loadMapsAction({ reload: false })); - this._store.dispatch(loadUsersAction({ reload: false })); - - effect(() => this._store.dispatch(loadEventAction({ eventId: this.eventId(), reload: true })), { - allowSignalWrites: true, - }); + keepMapsLoaded(); + keepUsersLoaded(); + keepEventLoaded(this.eventId); errorToastEffect(this.translations.events_error_delete, selectEventsActionState('remove')); errorToastEffect(this.translations.events_error_start, this.startActionState); diff --git a/src/client/src/app/components/events/event-timeslot-dialog/event-timeslot-dialog.component.ts b/src/client/src/app/components/events/event-timeslot-dialog/event-timeslot-dialog.component.ts index 5b47943..e2665c4 100644 --- a/src/client/src/app/components/events/event-timeslot-dialog/event-timeslot-dialog.component.ts +++ b/src/client/src/app/components/events/event-timeslot-dialog/event-timeslot-dialog.component.ts @@ -4,6 +4,7 @@ import { computed, effect, inject, + Injector, input, signal, untracked, @@ -23,7 +24,8 @@ import { selectEventsActionState, updateEventTimeslotAction, } from '../../../+state/events'; -import { loadMapsAction, mapSelectors } from '../../../+state/maps'; +import { mapSelectors } from '../../../+state/maps'; +import { keepMapsLoaded } from '../../../+state/maps/maps.utils'; import { ErrorTextDirective } from '../../../directives/error-text.directive'; import { Event, EventTimeslot } from '../../../models/parsed-models'; import { TranslateService } from '../../../services/translate.service'; @@ -56,6 +58,7 @@ import { hasTouchScreen } from '../../../utils/user-agent.utils'; export class EventTimeslotDialogComponent { private readonly _store = inject(Store); private readonly _formBuilder = inject(FormBuilder); + private readonly _injector = inject(Injector); public readonly event = input.required(); public readonly timeslot = input(null); @@ -124,7 +127,7 @@ export class EventTimeslotDialogComponent { } public open() { - this._store.dispatch(loadMapsAction({ reload: false })); + keepMapsLoaded({ injector: this._injector, enabled: this.visible }); this.visible.set(true); } 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 f2c5fb7..833b829 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 @@ -1,5 +1,5 @@ import { CommonModule } from '@angular/common'; -import { ChangeDetectionStrategy, Component, computed, effect, inject } from '@angular/core'; +import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core'; import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop'; import { FormsModule } from '@angular/forms'; import { Router, ActivatedRoute } from '@angular/router'; @@ -18,7 +18,6 @@ import { map } from 'rxjs'; import { isActionBusy, hasActionFailed } from '../../../+state/action-state'; import { addPlayerToEventPreconfigurationAction, - loadEventAction, removeEventPreconfigAction, removeEventTimeslotAction, removePlayerFromPreconfigAction, @@ -27,8 +26,15 @@ import { selectEventsActionState, } from '../../../+state/events'; import { addEventPreconfigAction } from '../../../+state/events/actions/add-event-preconfig.action'; -import { loadMapsAction, mapSelectors } from '../../../+state/maps'; -import { loadUsersAction, selectUsersActionState, userSelectors } from '../../../+state/users'; +import { keepEventLoaded } from '../../../+state/events/events.utils'; +import { mapSelectors } from '../../../+state/maps'; +import { keepMapsLoaded } from '../../../+state/maps/maps.utils'; +import { + keepUsersLoaded, + loadUsersAction, + selectUsersActionState, + userSelectors, +} from '../../../+state/users'; import { InterpolatePipe, interpolate } from '../../../directives/interpolate.pipe'; import { EventInstancePreconfiguration, User } from '../../../models/parsed-models'; import { TranslateService } from '../../../services/translate.service'; @@ -121,12 +127,9 @@ export class EventTimeslotComponent { constructor() { const actions$ = inject(Actions); - this._store.dispatch(loadMapsAction({ reload: false })); - this._store.dispatch(loadUsersAction({ reload: false })); - - effect(() => this._store.dispatch(loadEventAction({ eventId: this.eventId(), reload: true })), { - allowSignalWrites: true, - }); + keepMapsLoaded(); + keepUsersLoaded(); + keepEventLoaded(this.eventId); errorToastEffect( this.translations.events_error_addPlayerToPreconfig, diff --git a/src/client/src/app/components/events/events.component.ts b/src/client/src/app/components/events/events.component.ts index 73c43b2..0cba4b5 100644 --- a/src/client/src/app/components/events/events.component.ts +++ b/src/client/src/app/components/events/events.component.ts @@ -1,5 +1,5 @@ import { CommonModule, DatePipe } from '@angular/common'; -import { ChangeDetectionStrategy, Component, OnInit, computed, inject } from '@angular/core'; +import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { ActivatedRoute, Router, RouterLink } from '@angular/router'; import { Actions, ofType } from '@ngrx/effects'; @@ -20,6 +20,7 @@ import { loadEventsAction, selectEventsActionState, } from '../../+state/events'; +import { keepEventsLoaded } from '../../+state/events/events.utils'; import { TranslateService } from '../../services/translate.service'; import { selectSignal } from '../../utils/ngrx.utils'; @@ -42,7 +43,7 @@ import { selectSignal } from '../../utils/ngrx.utils'; changeDetection: ChangeDetectionStrategy.OnPush, providers: [DatePipe], }) -export class EventsComponent implements OnInit { +export class EventsComponent { private readonly _store = inject(Store); private readonly _router = inject(Router); private readonly _activatedRoute = inject(ActivatedRoute); @@ -68,6 +69,8 @@ export class EventsComponent implements OnInit { ); constructor() { + keepEventsLoaded(); + const actions$ = inject(Actions); actions$ .pipe(takeUntilDestroyed(), ofType(addEventAction.success)) @@ -76,12 +79,6 @@ export class EventsComponent implements OnInit { ); } - public ngOnInit(): void { - if (this.actionState().state === 'none') { - this._store.dispatch(loadEventsAction({ reload: false })); - } - } - protected loadNextPage(): void { this._store.dispatch(loadEventsAction({ reload: false })); } diff --git a/src/client/src/app/components/maps/maps.component.ts b/src/client/src/app/components/maps/maps.component.ts index 2b5f3c9..776d29d 100644 --- a/src/client/src/app/components/maps/maps.component.ts +++ b/src/client/src/app/components/maps/maps.component.ts @@ -13,12 +13,8 @@ import { ProgressSpinnerModule } from 'primeng/progressspinner'; import { MapDialogComponent } from './map-dialog/map-dialog.component'; import { MapItemComponent } from './map-item/map-item.component'; import { hasActionFailed, isActionBusy } from '../../+state/action-state'; -import { - loadMapsAction, - mapSelectors, - removeMapAction, - selectMapsActionState, -} from '../../+state/maps'; +import { mapSelectors, removeMapAction, selectMapsActionState } from '../../+state/maps'; +import { keepMapsLoaded } from '../../+state/maps/maps.utils'; import { interpolate } from '../../directives/interpolate.pipe'; import { MinigolfMap } from '../../models/parsed-models'; import { TranslateService } from '../../services/translate.service'; @@ -64,7 +60,7 @@ export class MapsComponent { protected readonly hasFailed = computed(() => hasActionFailed(this._actionState())); constructor() { - this._store.dispatch(loadMapsAction({ reload: false })); + keepMapsLoaded(); const action$ = inject(Actions); action$ diff --git a/src/client/src/app/components/player-events/player-event-details/player-event-details.component.ts b/src/client/src/app/components/player-events/player-event-details/player-event-details.component.ts index 1a67305..0196c41 100644 --- a/src/client/src/app/components/player-events/player-event-details/player-event-details.component.ts +++ b/src/client/src/app/components/player-events/player-event-details/player-event-details.component.ts @@ -25,11 +25,11 @@ import { filter, map, Subject, timer } from 'rxjs'; import { FadingMessageComponent } from '../../+common/fading-message.component'; import { hasActionFailed, isActionBusy } from '../../../+state/action-state'; import { - loadPlayerEventAction, updateEventRegistrationAction, selectPlayerEvent, selectPlayerEventsActionState, } from '../../../+state/player-events'; +import { keepPlayerEventLoaded } from '../../../+state/player-events/player-events.utils'; import { ApiEventTimeslotRegistration } from '../../../api/models'; import { ResetNgModelDirective } from '../../../directives/reset-ng-model.directive'; import { PlayerEventTimeslot } from '../../../models/parsed-models'; @@ -142,14 +142,7 @@ export class PlayerEventDetailsComponent { ); constructor() { - effect( - () => { - if (!this.event()) { - this._store.dispatch(loadPlayerEventAction({ eventId: this.eventId() })); - } - }, - { allowSignalWrites: true } - ); + keepPlayerEventLoaded(this.eventId); effect( () => { diff --git a/src/client/src/app/components/player-events/player-events.component.ts b/src/client/src/app/components/player-events/player-events.component.ts index cceead4..a8c521a 100644 --- a/src/client/src/app/components/player-events/player-events.component.ts +++ b/src/client/src/app/components/player-events/player-events.component.ts @@ -8,11 +8,8 @@ import { ProgressSpinnerModule } from 'primeng/progressspinner'; import { map, timer } from 'rxjs'; import { hasActionFailed, isActionBusy } from '../../+state/action-state'; -import { - loadPlayerEventsAction, - playerEventSelectors, - selectPlayerEventsActionState, -} from '../../+state/player-events'; +import { playerEventSelectors, selectPlayerEventsActionState } from '../../+state/player-events'; +import { keepPlayerEventsLoaded } from '../../+state/player-events/player-events.utils'; import { InterpolatePipe } from '../../directives/interpolate.pipe'; import { PlayerEvent } from '../../models/parsed-models'; import { AuthService } from '../../services/auth.service'; @@ -57,7 +54,7 @@ export class PlayerEventsComponent { protected readonly hasLoadFailed = computed(() => hasActionFailed(this.loadActionState())); constructor() { - this._store.dispatch(loadPlayerEventsAction({ reload: false })); + keepPlayerEventsLoaded(); } protected getRegisteredTimeslotsCount(event: PlayerEvent) { 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 e22dcc2..c6621a5 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 @@ -18,11 +18,11 @@ import { filter, first, map, Subject } from 'rxjs'; import { SavedFadingMessageComponent } from '../+common/saved-fading-message.component'; import { isActionBusy, hasActionFailed } from '../../+state/action-state'; import { - loadUserSettingsAction, selectUserSettings, selectUserSettingsActionState, updateUserSettingsAction, } from '../../+state/user-settings'; +import { keepUserSettingsLoaded } from '../../+state/user-settings/user-settings.utils'; import { ResetNgModelDirective } from '../../directives/reset-ng-model.directive'; import { UserSettings } from '../../models/parsed-models'; import { AuthService } from '../../services/auth.service'; @@ -155,7 +155,7 @@ export class UserSettingsComponent { }; constructor() { - this._store.dispatch(loadUserSettingsAction({ reload: false })); + keepUserSettingsLoaded(); this._actions$ .pipe(takeUntilDestroyed(), ofType(updateUserSettingsAction.error)) diff --git a/src/client/src/app/components/users/users.component.ts b/src/client/src/app/components/users/users.component.ts index 43ceba1..4a111fe 100644 --- a/src/client/src/app/components/users/users.component.ts +++ b/src/client/src/app/components/users/users.component.ts @@ -12,7 +12,7 @@ import { UserDialogComponent } from './user-dialog/user-dialog.component'; import { UserItemComponent } from './user-item/user-item.component'; import { isActionBusy, hasActionFailed } from '../../+state/action-state'; import { - loadUsersAction, + keepUsersLoaded, removeUserAction, selectUsersActionState, userSelectors, @@ -59,7 +59,7 @@ export class UsersComponent { protected readonly hasFailed = computed(() => hasActionFailed(this._actionState())); constructor() { - this._store.dispatch(loadUsersAction({ reload: false })); + keepUsersLoaded(); } protected deleteUser(user: User) { diff --git a/src/client/src/app/models/realtime-events.ts b/src/client/src/app/models/realtime-events.ts new file mode 100644 index 0000000..52c0dd9 --- /dev/null +++ b/src/client/src/app/models/realtime-events.ts @@ -0,0 +1,42 @@ +export type RealtimeEventChangeType = 'created' | 'updated' | 'deleted'; + +export type UserChangedRealtimeEvent = { + userId: string; + changeType: RealtimeEventChangeType; +}; +export type MapChangedRealtimeEvent = { + mapId: string; + changeType: RealtimeEventChangeType; +}; +export type EventChangedRealtimeEvent = { + eventId: string; + changeType: RealtimeEventChangeType; +}; +export type EventTimeslotChangedRealtimeEvent = { + eventId: string; + eventTimeslotId: string; + changeType: RealtimeEventChangeType; +}; +export type EventInstancesChangedRealtimeEvent = { + eventId: string; +}; +export type EventPreconfigurationChangedRealtimeEvent = { + eventId: string; + eventTimeslotId: string; + eventPreconfigurationId: string; + changeType: RealtimeEventChangeType; +}; +export type PlayerEventChangedRealtimeEvent = { + eventId: string; + changeType: RealtimeEventChangeType; +}; +export type PlayerEventRegistrationChangedRealtimeEvent = { + eventId: string; +}; +export type UserSettingsChangedRealtimeEvent = {}; +export type PlayerEventTimeslotRegistrationChanged = { + eventId: string; + eventTimeslotId: string; + userId: string; + isRegistered: boolean; +}; diff --git a/src/client/src/app/services/realtime-events.service.ts b/src/client/src/app/services/realtime-events.service.ts new file mode 100644 index 0000000..ed9803f --- /dev/null +++ b/src/client/src/app/services/realtime-events.service.ts @@ -0,0 +1,162 @@ +import { + computed, + effect, + EventEmitter, + inject, + Injectable, + OnDestroy, + signal, + untracked, +} from '@angular/core'; +import { toObservable } from '@angular/core/rxjs-interop'; +import { HubConnection, HubConnectionBuilder } from '@microsoft/signalr'; +import { defer, EMPTY, EmptyError, filter, firstValueFrom, map, pairwise, startWith } from 'rxjs'; + +import { AuthService } from './auth.service'; +import { AuthTokenInfo } from './storage'; +import { + UserChangedRealtimeEvent, + MapChangedRealtimeEvent, + EventChangedRealtimeEvent, + EventTimeslotChangedRealtimeEvent, + EventInstancesChangedRealtimeEvent, + EventPreconfigurationChangedRealtimeEvent, + PlayerEventChangedRealtimeEvent, + PlayerEventRegistrationChangedRealtimeEvent, + UserSettingsChangedRealtimeEvent, + PlayerEventTimeslotRegistrationChanged, +} from '../models/realtime-events'; +import { SignalrRetryPolicy } from '../signalr-retry-policy'; +import { retryWithPolicy } from '../utils/rxjs.utils'; + +const USER_CHANGED = 'userChanged'; +const MAP_CHANGED = 'mapChanged'; +const EVENT_CHANGED = 'eventChanged'; +const EVENT_TIMESLOT_CHANGED = 'eventTimeslotChanged'; +const EVENT_INSTANCES_CHANGED = 'eventInstancesChanged'; +const EVENT_PRECONFIGURATION_CHANGED = 'eventPreconfigurationChanged'; +const PLAYER_EVENT_CHANGED = 'playerEventChanged'; +const PLAYER_EVENT_REGISTRATION_CHANGED = 'playerEventRegistrationChanged'; +const USER_SETTINGS_CHANGED = 'userSettingsChanged'; +const PLAYER_EVENT_TIMESLOT_REGISTRATION_CHANGED = 'playerEventTimeslotRegistrationChanged'; + +@Injectable({ providedIn: 'root' }) +export class RealtimeEventsService implements OnDestroy { + private readonly _authService = inject(AuthService); + private readonly _isConnected = signal(null); + private _hubConnection?: HubConnection; + + public readonly userChanged = new EventEmitter(); + public readonly mapChanged = new EventEmitter(); + public readonly eventChanged = new EventEmitter(); + public readonly eventTimeslotChanged = new EventEmitter(); + public readonly eventInstancesChanged = new EventEmitter(); + public readonly eventPreconfigurationChanged = + new EventEmitter(); + public readonly playerEventChanged = new EventEmitter(); + public readonly playerEventRegistrationChanged = + new EventEmitter(); + public readonly userSettingsChanged = new EventEmitter(); + public readonly playerEventTimeslotRegistrationChanged = + new EventEmitter(); + public readonly isConnected = computed(() => !!this._isConnected()); + public readonly onReconnected$ = toObservable(this._isConnected).pipe( + startWith(null), + pairwise(), + filter(([p, c]) => p === false && c === true), + map((): void => {}) + ); + + constructor() { + effect(() => { + const tokenInfo = this._authService.token(); + untracked(() => this.onLoginChanged(tokenInfo)); + }); + } + + public ngOnDestroy() { + this.disconnect(); + } + + private onLoginChanged(token: AuthTokenInfo | null | undefined) { + if (token) { + this.connect(); + } else { + this.disconnect(); + } + } + + private async connect() { + if (this._hubConnection) { + return; + } + + const connection = new HubConnectionBuilder() + .withUrl('/hubs/realtime-events', { + accessTokenFactory: () => this._authService.token()?.token ?? '', + withCredentials: false, + }) + .withAutomaticReconnect( + new SignalrRetryPolicy((error, nextDelay) => + console.warn( + `Realtime events connection lost. Retry connection in ${nextDelay}ms.`, + error, + nextDelay + ) + ) + ) + .build(); + + this.on(connection, USER_CHANGED, this.userChanged); + this.on(connection, MAP_CHANGED, this.mapChanged); + this.on(connection, EVENT_CHANGED, this.eventChanged); + this.on(connection, EVENT_TIMESLOT_CHANGED, this.eventTimeslotChanged); + this.on(connection, EVENT_INSTANCES_CHANGED, this.eventInstancesChanged); + this.on(connection, EVENT_PRECONFIGURATION_CHANGED, this.eventPreconfigurationChanged); + this.on(connection, PLAYER_EVENT_CHANGED, this.playerEventChanged); + this.on(connection, PLAYER_EVENT_REGISTRATION_CHANGED, this.playerEventRegistrationChanged); + this.on(connection, USER_SETTINGS_CHANGED, this.userSettingsChanged); + this.on( + connection, + PLAYER_EVENT_TIMESLOT_REGISTRATION_CHANGED, + this.playerEventTimeslotRegistrationChanged + ); + + connection.onreconnecting(() => this._isConnected.set(false)); + connection.onreconnected(() => this._isConnected.set(true)); + connection.onclose(() => this._isConnected.set(false)); + + this._hubConnection = connection; + try { + await firstValueFrom( + defer(() => this._hubConnection?.start() ?? EMPTY).pipe( + retryWithPolicy( + new SignalrRetryPolicy((error, nextDelay) => + console.warn( + `Realtime events connection unsuccessful. Retry connection in ${nextDelay}ms.`, + error + ) + ) + ) + ) + ); + this._isConnected.set(true); + } catch (ex) { + if (!(ex instanceof EmptyError)) { + throw ex; + } + } + } + + private on(connection: HubConnection, name: string, eventEmitter: EventEmitter) { + connection.on(name, (event: T) => eventEmitter.emit(event)); + } + + private async disconnect() { + if (this._hubConnection) { + const connection = this._hubConnection; + this._hubConnection = undefined; + await connection.stop(); + } + } +} diff --git a/src/client/src/app/signalr-retry-policy.ts b/src/client/src/app/signalr-retry-policy.ts new file mode 100644 index 0000000..82a42ea --- /dev/null +++ b/src/client/src/app/signalr-retry-policy.ts @@ -0,0 +1,20 @@ +import { IRetryPolicy, RetryContext } from '@microsoft/signalr'; + +const RETRY_DELAYS = [0, 2000, 5000, 10000, 15000, 20000, 30000]; + +export class SignalrRetryPolicy implements IRetryPolicy { + private readonly _onRetry: (error: Error, nextDelay: number) => void; + + constructor(onRetry: (error: Error, nextDelay: number) => void) { + this._onRetry = onRetry; + } + + public nextRetryDelayInMilliseconds(retryContext: RetryContext): number | null { + const delay = + retryContext.previousRetryCount < RETRY_DELAYS.length + ? RETRY_DELAYS[retryContext.previousRetryCount] + : RETRY_DELAYS[RETRY_DELAYS.length - 1]; + this._onRetry(retryContext.retryReason, delay); + return delay; + } +} diff --git a/src/client/src/app/utils/rxjs.utils.ts b/src/client/src/app/utils/rxjs.utils.ts new file mode 100644 index 0000000..010f84b --- /dev/null +++ b/src/client/src/app/utils/rxjs.utils.ts @@ -0,0 +1,15 @@ +import { IRetryPolicy } from '@microsoft/signalr'; +import { retry, throwError, timer } from 'rxjs'; + +export function retryWithPolicy(policy: IRetryPolicy) { + return retry({ + delay: (error, retryCount) => { + const nextDelay = policy.nextRetryDelayInMilliseconds({ + elapsedMilliseconds: 0, + previousRetryCount: retryCount, + retryReason: error, + }); + return nextDelay === null ? throwError(() => error) : timer(nextDelay); + }, + }); +} diff --git a/src/server/domain/Models/RealtimeEvents/RealtimeEvent.cs b/src/server/domain/Models/RealtimeEvents/RealtimeEvent.cs new file mode 100644 index 0000000..f1eeb66 --- /dev/null +++ b/src/server/domain/Models/RealtimeEvents/RealtimeEvent.cs @@ -0,0 +1,125 @@ +using System.Text.Json.Serialization; + +namespace MinigolfFriday.Domain.Models.RealtimeEvents; + +public interface IRealtimeEvent +{ + static abstract string MethodName { get; } +} + +public interface IGroupRealtimeEvent : IRealtimeEvent +{ + RealtimeEventGroup Group { get; } +} + +public interface IUserRealtimeEvent : IRealtimeEvent +{ + string UserId { get; } +} + +public static class RealtimeEvent +{ + /// Event that is triggered when a user changed. + public record UserChanged(string UserId, RealtimeEventChangeType ChangeType) + : IGroupRealtimeEvent + { + public static string MethodName => "UserChanged"; + + [JsonIgnore] + public RealtimeEventGroup Group => RealtimeEventGroup.Admin; + } + + /// Event that is triggered when a map changed. + public record MapChanged(string MapId, RealtimeEventChangeType ChangeType) : IGroupRealtimeEvent + { + public static string MethodName => "MapChanged"; + + [JsonIgnore] + public RealtimeEventGroup Group => RealtimeEventGroup.Admin; + } + + /// Event that is triggered when an event changed (does not trigger for instances, preconfigurations or timeslots). + public record EventChanged(string EventId, RealtimeEventChangeType ChangeType) + : IGroupRealtimeEvent + { + public static string MethodName => "EventChanged"; + + [JsonIgnore] + public RealtimeEventGroup Group => RealtimeEventGroup.Admin; + } + + /// Event that is triggered when a timeslot of an event changed (does not trigger for instances or preconfigurations). + public record EventTimeslotChanged( + string EventId, + string EventTimeslotId, + RealtimeEventChangeType ChangeType + ) : IGroupRealtimeEvent + { + public static string MethodName => "EventTimeslotChanged"; + + [JsonIgnore] + public RealtimeEventGroup Group => RealtimeEventGroup.Admin; + } + + /// Event that is triggered when instances of an event changed. + public record EventInstancesChanged(string EventId) : IGroupRealtimeEvent + { + public static string MethodName => "EventInstancesChanged"; + + [JsonIgnore] + public RealtimeEventGroup Group => RealtimeEventGroup.Admin; + } + + /// Event that is triggered when a preconfiguration of an event timeslot changed. + public record EventPreconfigurationChanged( + string EventId, + string EventTimeslotId, + string EventPreconfigurationId, + RealtimeEventChangeType ChangeType + ) : IGroupRealtimeEvent + { + public static string MethodName => "EventPreconfigurationChanged"; + + [JsonIgnore] + public RealtimeEventGroup Group => RealtimeEventGroup.Admin; + } + + /// Event that is triggered when a player event changed. + public record PlayerEventChanged(string EventId, RealtimeEventChangeType ChangeType) + : IGroupRealtimeEvent + { + public static string MethodName => "PlayerEventChanged"; + + [JsonIgnore] + public RealtimeEventGroup Group => RealtimeEventGroup.Player; + } + + /// Event that is triggered when registrations of a player for an event changed. + public record PlayerEventRegistrationChanged( + [property: JsonIgnore] string UserId, + string EventId + ) : IUserRealtimeEvent + { + public static string MethodName => "PlayerEventRegistrationChanged"; + } + + /// Event that is triggered when settings of a user changed. + public record UserSettingsChanged([property: JsonIgnore] string UserId) : IUserRealtimeEvent + { + public static string MethodName => "UserSettingsChanged"; + } + + /// Event that is triggered when a player event timeslot registration changed. + public record PlayerEventTimeslotRegistrationChanged( + string EventId, + string EventTimeslotId, + string UserId, + bool IsRegistered + ) : IGroupRealtimeEvent + { + public static string MethodName => "PlayerEventTimeslotRegistrationChanged"; + + [JsonIgnore] + public RealtimeEventGroup Group => RealtimeEventGroup.All; + } +} diff --git a/src/server/domain/Models/RealtimeEvents/RealtimeEventChangeType.cs b/src/server/domain/Models/RealtimeEvents/RealtimeEventChangeType.cs new file mode 100644 index 0000000..41d7190 --- /dev/null +++ b/src/server/domain/Models/RealtimeEvents/RealtimeEventChangeType.cs @@ -0,0 +1,8 @@ +namespace MinigolfFriday.Domain.Models.RealtimeEvents; + +public enum RealtimeEventChangeType +{ + Created, + Updated, + Deleted +} diff --git a/src/server/domain/Models/RealtimeEvents/RealtimeEventGroup.cs b/src/server/domain/Models/RealtimeEvents/RealtimeEventGroup.cs new file mode 100644 index 0000000..b9df269 --- /dev/null +++ b/src/server/domain/Models/RealtimeEvents/RealtimeEventGroup.cs @@ -0,0 +1,8 @@ +namespace MinigolfFriday.Domain.Models.RealtimeEvents; + +public enum RealtimeEventGroup +{ + All, + Admin, + Player +} diff --git a/src/server/host/Endpoints/Administration/Events/CreateEventEndpoint.cs b/src/server/host/Endpoints/Administration/Events/CreateEventEndpoint.cs index e809548..7388023 100644 --- a/src/server/host/Endpoints/Administration/Events/CreateEventEndpoint.cs +++ b/src/server/host/Endpoints/Administration/Events/CreateEventEndpoint.cs @@ -6,6 +6,7 @@ using MinigolfFriday.Data.Entities; using MinigolfFriday.Domain.Models; using MinigolfFriday.Domain.Models.Push; +using MinigolfFriday.Domain.Models.RealtimeEvents; using MinigolfFriday.Host.Mappers; using MinigolfFriday.Host.Services; @@ -31,8 +32,12 @@ public CreateEventRequestValidator() } /// Create a new event. -public class CreateEventEndpoint(DatabaseContext databaseContext, IEventMapper eventMapper) - : Endpoint +public class CreateEventEndpoint( + DatabaseContext databaseContext, + IRealtimeEventsService realtimeEventsService, + IEventMapper eventMapper, + IIdService idService +) : Endpoint { public override void Configure() { @@ -51,7 +56,14 @@ public override async Task HandleAsync(CreateEventRequest req, CancellationToken }; databaseContext.Events.Add(entity); await databaseContext.SaveChangesAsync(ct); - await SendAsync(new(eventMapper.Map(entity)), 201, ct); + + await realtimeEventsService.SendEventAsync( + new RealtimeEvent.EventChanged( + idService.Event.Encode(entity.Id), + RealtimeEventChangeType.Created + ), + ct + ); } } diff --git a/src/server/host/Endpoints/Administration/Events/DeleteEventEndpoint.cs b/src/server/host/Endpoints/Administration/Events/DeleteEventEndpoint.cs index 11c1d52..5d32a3f 100644 --- a/src/server/host/Endpoints/Administration/Events/DeleteEventEndpoint.cs +++ b/src/server/host/Endpoints/Administration/Events/DeleteEventEndpoint.cs @@ -3,6 +3,7 @@ using FluentValidation; using Microsoft.EntityFrameworkCore; using MinigolfFriday.Data; +using MinigolfFriday.Domain.Models.RealtimeEvents; using MinigolfFriday.Host.Common; using MinigolfFriday.Host.Services; @@ -20,8 +21,11 @@ public DeleteEventRequestValidator(IIdService idService) } /// Delete an event. -public class DeleteEventEndpoint(DatabaseContext databaseContext, IIdService idService) - : Endpoint +public class DeleteEventEndpoint( + DatabaseContext databaseContext, + IRealtimeEventsService realtimeEventsService, + IIdService idService +) : Endpoint { public override void Configure() { @@ -35,7 +39,7 @@ public override async Task HandleAsync(DeleteEventRequest req, CancellationToken var eventId = idService.Event.DecodeSingle(req.EventId); var info = await databaseContext .Events.Where(x => x.Id == eventId) - .Select(x => new { Started = x.StartedAt != null }) + .Select(x => new { Started = x.StartedAt != null, x.Staged }) .FirstOrDefaultAsync(ct); if (info == null) @@ -54,5 +58,23 @@ public override async Task HandleAsync(DeleteEventRequest req, CancellationToken await databaseContext.Events.Where(x => x.Id == eventId).ExecuteDeleteAsync(ct); await SendOkAsync(ct); + + await realtimeEventsService.SendEventAsync( + new RealtimeEvent.EventChanged( + idService.Event.Encode(eventId), + RealtimeEventChangeType.Deleted + ), + ct + ); + if (!info.Staged) + { + await realtimeEventsService.SendEventAsync( + new RealtimeEvent.PlayerEventChanged( + idService.Event.Encode(eventId), + RealtimeEventChangeType.Deleted + ), + ct + ); + } } } diff --git a/src/server/host/Endpoints/Administration/Events/Instances/BuildEventInstancesEndpoint.cs b/src/server/host/Endpoints/Administration/Events/Instances/BuildEventInstancesEndpoint.cs index 28f87af..906ef77 100644 --- a/src/server/host/Endpoints/Administration/Events/Instances/BuildEventInstancesEndpoint.cs +++ b/src/server/host/Endpoints/Administration/Events/Instances/BuildEventInstancesEndpoint.cs @@ -2,6 +2,7 @@ using FastEndpoints; using FluentValidation; using MinigolfFriday.Domain.Models; +using MinigolfFriday.Domain.Models.RealtimeEvents; using MinigolfFriday.Host.Common; using MinigolfFriday.Host.Services; @@ -27,6 +28,7 @@ public BuildEventInstancesRequestValidator(IIdService idService) /// Build event instances. public class BuildEventInstancesEndpoint( + IRealtimeEventsService realtimeEventsService, IIdService idService, IEventInstanceService eventInstanceService ) : Endpoint @@ -54,5 +56,23 @@ public override async Task HandleAsync(BuildEventInstancesRequest req, Cancellat await eventInstanceService.PersistEventInstancesAsync(instances, ct); await SendAsync(new(instances, persist), cancellation: ct); + + if (persist) + { + await realtimeEventsService.SendEventAsync( + new RealtimeEvent.EventInstancesChanged(idService.Event.Encode(eventId)), + ct + ); + if (!@event.Staged) + { + await realtimeEventsService.SendEventAsync( + new RealtimeEvent.PlayerEventChanged( + idService.Event.Encode(eventId), + RealtimeEventChangeType.Updated + ), + ct + ); + } + } } } diff --git a/src/server/host/Endpoints/Administration/Events/Preconfigurations/AddPlayersToPreconfigurationEndpoint.cs b/src/server/host/Endpoints/Administration/Events/Preconfigurations/AddPlayersToPreconfigurationEndpoint.cs index d404c81..d993ba3 100644 --- a/src/server/host/Endpoints/Administration/Events/Preconfigurations/AddPlayersToPreconfigurationEndpoint.cs +++ b/src/server/host/Endpoints/Administration/Events/Preconfigurations/AddPlayersToPreconfigurationEndpoint.cs @@ -3,6 +3,7 @@ using FluentValidation; using Microsoft.EntityFrameworkCore; using MinigolfFriday.Data; +using MinigolfFriday.Domain.Models.RealtimeEvents; using MinigolfFriday.Host.Common; using MinigolfFriday.Host.Services; @@ -28,6 +29,7 @@ public AddPlayersToPreconfigurationRequestValidator(IIdService idService) /// Add players to an event instance preconfiguration. public class AddPlayersToPreconfigurationEndpoint( DatabaseContext databaseContext, + IRealtimeEventsService realtimeEventsService, IIdService idService ) : Endpoint { @@ -54,7 +56,8 @@ CancellationToken ct .Select(x => new { Started = x.EventTimeSlot.Event.StartedAt != null, - x.EventTimeSlot.EventId + x.EventTimeSlot.EventId, + TimeslotId = x.EventTimeSlot.Id }) .FirstOrDefaultAsync(ct); @@ -86,5 +89,15 @@ await this.SendErrorAsync( ); await databaseContext.SaveChangesAsync(ct); await SendAsync(null, cancellation: ct); + + await realtimeEventsService.SendEventAsync( + new RealtimeEvent.EventPreconfigurationChanged( + idService.Event.Encode(preconfigInfo.EventId), + idService.EventTimeslot.Encode(preconfigInfo.TimeslotId), + idService.Preconfiguration.Encode(preconfigId), + RealtimeEventChangeType.Updated + ), + ct + ); } } diff --git a/src/server/host/Endpoints/Administration/Events/Preconfigurations/CreatePreconfigurationEndpoint.cs b/src/server/host/Endpoints/Administration/Events/Preconfigurations/CreatePreconfigurationEndpoint.cs index 1556610..acbf9b4 100644 --- a/src/server/host/Endpoints/Administration/Events/Preconfigurations/CreatePreconfigurationEndpoint.cs +++ b/src/server/host/Endpoints/Administration/Events/Preconfigurations/CreatePreconfigurationEndpoint.cs @@ -5,6 +5,7 @@ using MinigolfFriday.Data; using MinigolfFriday.Data.Entities; using MinigolfFriday.Domain.Models; +using MinigolfFriday.Domain.Models.RealtimeEvents; using MinigolfFriday.Host.Common; using MinigolfFriday.Host.Mappers; using MinigolfFriday.Host.Services; @@ -30,6 +31,7 @@ public CreatePreconfigurationRequestValidator(IIdService idService) /// Create a new event instance preconfiguration for a given event timeslot. public class CreatePreconfigurationEndpoint( DatabaseContext databaseContext, + IRealtimeEventsService realtimeEventsService, IEventMapper eventMapper, IIdService idService ) : Endpoint @@ -78,5 +80,15 @@ await this.SendErrorAsync( databaseContext.EventInstancePreconfigurations.Add(preconfig); await databaseContext.SaveChangesAsync(ct); await SendAsync(new(eventMapper.Map(preconfig)), 201, ct); + + await realtimeEventsService.SendEventAsync( + new RealtimeEvent.EventPreconfigurationChanged( + idService.Event.Encode(timeslotInfo.EventId), + idService.EventTimeslot.Encode(timeslotId), + idService.Preconfiguration.Encode(preconfig.Id), + RealtimeEventChangeType.Created + ), + ct + ); } } diff --git a/src/server/host/Endpoints/Administration/Events/Preconfigurations/DeletePreconfigurationEndpoint.cs b/src/server/host/Endpoints/Administration/Events/Preconfigurations/DeletePreconfigurationEndpoint.cs index 88247f7..bb6187d 100644 --- a/src/server/host/Endpoints/Administration/Events/Preconfigurations/DeletePreconfigurationEndpoint.cs +++ b/src/server/host/Endpoints/Administration/Events/Preconfigurations/DeletePreconfigurationEndpoint.cs @@ -3,6 +3,7 @@ using FluentValidation; using Microsoft.EntityFrameworkCore; using MinigolfFriday.Data; +using MinigolfFriday.Domain.Models.RealtimeEvents; using MinigolfFriday.Host.Common; using MinigolfFriday.Host.Services; @@ -20,8 +21,11 @@ public DeletePreconfigurationRequestValidator(IIdService idService) } /// Delete an event instance preconfiguration. -public class DeletePreconfigurationEndpoint(DatabaseContext databaseContext, IIdService idService) - : Endpoint +public class DeletePreconfigurationEndpoint( + DatabaseContext databaseContext, + IRealtimeEventsService realtimeEventsService, + IIdService idService +) : Endpoint { public override void Configure() { @@ -43,7 +47,8 @@ public override async Task HandleAsync(DeletePreconfigurationRequest req, Cancel .Select(x => new { Started = x.EventTimeSlot.Event.StartedAt != null, - x.EventTimeSlot.EventId + x.EventTimeSlot.EventId, + TimeslotId = x.EventTimeSlot.Id }) .FirstOrDefaultAsync(ct); @@ -71,5 +76,15 @@ await this.SendErrorAsync( await preconfigQuery.ExecuteDeleteAsync(ct); await SendAsync(null, cancellation: ct); + + await realtimeEventsService.SendEventAsync( + new RealtimeEvent.EventPreconfigurationChanged( + idService.Event.Encode(preconfigInfo.EventId), + idService.EventTimeslot.Encode(preconfigInfo.TimeslotId), + idService.Preconfiguration.Encode(preconfigId), + RealtimeEventChangeType.Deleted + ), + ct + ); } } diff --git a/src/server/host/Endpoints/Administration/Events/Preconfigurations/RemovePlayersFromPreconfigurationEndpoint.cs b/src/server/host/Endpoints/Administration/Events/Preconfigurations/RemovePlayersFromPreconfigurationEndpoint.cs index 2df5137..ba5c581 100644 --- a/src/server/host/Endpoints/Administration/Events/Preconfigurations/RemovePlayersFromPreconfigurationEndpoint.cs +++ b/src/server/host/Endpoints/Administration/Events/Preconfigurations/RemovePlayersFromPreconfigurationEndpoint.cs @@ -3,6 +3,7 @@ using FluentValidation; using Microsoft.EntityFrameworkCore; using MinigolfFriday.Data; +using MinigolfFriday.Domain.Models.RealtimeEvents; using MinigolfFriday.Host.Common; using MinigolfFriday.Host.Services; @@ -28,6 +29,7 @@ public RemovePlayersFromPreconfigurationRequestValidator(IIdService idService) /// Remove players from an event instance preconfiguration. public class RemovePlayersFromPreconfigurationEndpoint( DatabaseContext databaseContext, + IRealtimeEventsService realtimeEventsService, IIdService idService ) : Endpoint { @@ -58,7 +60,8 @@ CancellationToken ct .Select(x => new { Started = x.EventTimeSlot.Event.StartedAt != null, - x.EventTimeSlot.EventId + x.EventTimeSlot.EventId, + TimeslotId = x.EventTimeSlot.Id }) .FirstOrDefaultAsync(ct); @@ -90,5 +93,15 @@ await this.SendErrorAsync( entity.Players.Remove(databaseContext.UserById(idService.User.DecodeSingle(playerId))); await databaseContext.SaveChangesAsync(ct); await SendAsync(null, cancellation: ct); + + await realtimeEventsService.SendEventAsync( + new RealtimeEvent.EventPreconfigurationChanged( + idService.Event.Encode(preconfigInfo.EventId), + idService.EventTimeslot.Encode(preconfigInfo.TimeslotId), + idService.Preconfiguration.Encode(preconfigId), + RealtimeEventChangeType.Updated + ), + ct + ); } } diff --git a/src/server/host/Endpoints/Administration/Events/StartEventEndpoint.cs b/src/server/host/Endpoints/Administration/Events/StartEventEndpoint.cs index 1f7cd99..a8ca130 100644 --- a/src/server/host/Endpoints/Administration/Events/StartEventEndpoint.cs +++ b/src/server/host/Endpoints/Administration/Events/StartEventEndpoint.cs @@ -4,6 +4,7 @@ using Microsoft.EntityFrameworkCore; using MinigolfFriday.Data; using MinigolfFriday.Domain.Models.Push; +using MinigolfFriday.Domain.Models.RealtimeEvents; using MinigolfFriday.Host.Common; using MinigolfFriday.Host.Mappers; using MinigolfFriday.Host.Services; @@ -24,6 +25,7 @@ public StartEventRequestValidator(IIdService idService) /// Starts an event. public class StartEventEndpoint( DatabaseContext databaseContext, + IRealtimeEventsService realtimeEventsService, IIdService idService, IUserPushSubscriptionMapper userPushSubscriptionMapper, IWebPushService webPushService @@ -106,6 +108,22 @@ await this.SendErrorAsync( await databaseContext .Events.Where(x => x.Id == eventId) .ExecuteUpdateAsync(x => x.SetProperty(x => x.StartedAt, now), ct); + await SendAsync(null, cancellation: ct); + + await realtimeEventsService.SendEventAsync( + new RealtimeEvent.EventChanged( + idService.Event.Encode(eventId), + RealtimeEventChangeType.Updated + ), + ct + ); + await realtimeEventsService.SendEventAsync( + new RealtimeEvent.PlayerEventChanged( + idService.Event.Encode(eventId), + RealtimeEventChangeType.Updated + ), + ct + ); var pushSubscription = await databaseContext .Events.Where(x => x.Id == eventId) @@ -124,7 +142,5 @@ await webPushService.SendAsync( new PushNotificationData.EventStarted(idService.Event.Encode(eventId)), ct ); - - await SendAsync(null, cancellation: ct); } } diff --git a/src/server/host/Endpoints/Administration/Events/Timeslots/CreateEventTimeslotEndpoint.cs b/src/server/host/Endpoints/Administration/Events/Timeslots/CreateEventTimeslotEndpoint.cs index a0c3aa1..23ed3bc 100644 --- a/src/server/host/Endpoints/Administration/Events/Timeslots/CreateEventTimeslotEndpoint.cs +++ b/src/server/host/Endpoints/Administration/Events/Timeslots/CreateEventTimeslotEndpoint.cs @@ -2,10 +2,12 @@ using FastEndpoints; using FluentValidation; using FluentValidation.Results; +using Microsoft.AspNetCore.Identity.Data; using Microsoft.EntityFrameworkCore; using MinigolfFriday.Data; using MinigolfFriday.Data.Entities; using MinigolfFriday.Domain.Models; +using MinigolfFriday.Domain.Models.RealtimeEvents; using MinigolfFriday.Host.Common; using MinigolfFriday.Host.Mappers; using MinigolfFriday.Host.Services; @@ -39,6 +41,7 @@ public CreateEventTimeslotRequestValidator(IIdService idService) /// Create an event timeslot. public class CreateEventTimeslotEndpoint( DatabaseContext databaseContext, + IRealtimeEventsService realtimeEventsService, IIdService idService, IEventMapper eventMapper ) : Endpoint @@ -56,7 +59,7 @@ public override async Task HandleAsync(CreateEventTimeslotRequest req, Cancellat var eventId = idService.Event.DecodeSingle(req.EventId); var eventInfo = await databaseContext .Events.Where(x => x.Id == eventId) - .Select(x => new { Started = x.StartedAt != null }) + .Select(x => new { Started = x.StartedAt != null, x.Staged }) .FirstOrDefaultAsync(ct); if (eventInfo == null) @@ -99,5 +102,24 @@ public override async Task HandleAsync(CreateEventTimeslotRequest req, Cancellat databaseContext.EventTimeslots.Add(timeslot); await databaseContext.SaveChangesAsync(ct); await SendAsync(new(eventMapper.Map(timeslot)), 201, ct); + + await realtimeEventsService.SendEventAsync( + new RealtimeEvent.EventTimeslotChanged( + idService.Event.Encode(eventId), + idService.EventTimeslot.Encode(timeslot.Id), + RealtimeEventChangeType.Created + ), + ct + ); + if (!eventInfo.Staged) + { + await realtimeEventsService.SendEventAsync( + new RealtimeEvent.PlayerEventChanged( + idService.Event.Encode(eventId), + RealtimeEventChangeType.Updated + ), + ct + ); + } } } diff --git a/src/server/host/Endpoints/Administration/Events/Timeslots/DeleteEventTimeslotEndpoint.cs b/src/server/host/Endpoints/Administration/Events/Timeslots/DeleteEventTimeslotEndpoint.cs index f57ee00..4f29c2c 100644 --- a/src/server/host/Endpoints/Administration/Events/Timeslots/DeleteEventTimeslotEndpoint.cs +++ b/src/server/host/Endpoints/Administration/Events/Timeslots/DeleteEventTimeslotEndpoint.cs @@ -3,6 +3,7 @@ using FluentValidation; using Microsoft.EntityFrameworkCore; using MinigolfFriday.Data; +using MinigolfFriday.Domain.Models.RealtimeEvents; using MinigolfFriday.Host.Common; using MinigolfFriday.Host.Services; @@ -20,8 +21,11 @@ public DeleteEventTimeslotRequestValidator(IIdService idService) } /// Delete an event timeslot. -public class DeleteEventTimeslotEndpoint(DatabaseContext databaseContext, IIdService idService) - : Endpoint +public class DeleteEventTimeslotEndpoint( + DatabaseContext databaseContext, + IRealtimeEventsService realtimeEventsService, + IIdService idService +) : Endpoint { public override void Configure() { @@ -38,7 +42,12 @@ public override async Task HandleAsync(DeleteEventTimeslotRequest req, Cancellat var timeslotId = idService.EventTimeslot.DecodeSingle(req.TimeslotId); var timeslotQuery = databaseContext.EventTimeslots.Where(x => x.Id == timeslotId); var timeslotInfo = await timeslotQuery - .Select(x => new { EventStarted = x.Event.StartedAt != null, x.EventId }) + .Select(x => new + { + EventStarted = x.Event.StartedAt != null, + x.EventId, + x.Event.Staged + }) .FirstOrDefaultAsync(ct); if (timeslotInfo == null) @@ -61,5 +70,24 @@ await this.SendErrorAsync( await timeslotQuery.ExecuteDeleteAsync(ct); await SendAsync(null, cancellation: ct); + + await realtimeEventsService.SendEventAsync( + new RealtimeEvent.EventTimeslotChanged( + idService.Event.Encode(timeslotInfo.EventId), + idService.EventTimeslot.Encode(timeslotId), + RealtimeEventChangeType.Deleted + ), + ct + ); + if (!timeslotInfo.Staged) + { + await realtimeEventsService.SendEventAsync( + new RealtimeEvent.PlayerEventChanged( + idService.Event.Encode(timeslotInfo.EventId), + RealtimeEventChangeType.Updated + ), + ct + ); + } } } diff --git a/src/server/host/Endpoints/Administration/Events/Timeslots/UpdateEventTimeslotEndpoint.cs b/src/server/host/Endpoints/Administration/Events/Timeslots/UpdateEventTimeslotEndpoint.cs index f0681a1..369c289 100644 --- a/src/server/host/Endpoints/Administration/Events/Timeslots/UpdateEventTimeslotEndpoint.cs +++ b/src/server/host/Endpoints/Administration/Events/Timeslots/UpdateEventTimeslotEndpoint.cs @@ -4,6 +4,7 @@ using FluentValidation.Results; using Microsoft.EntityFrameworkCore; using MinigolfFriday.Data; +using MinigolfFriday.Domain.Models.RealtimeEvents; using MinigolfFriday.Host.Common; using MinigolfFriday.Host.Services; @@ -28,8 +29,11 @@ public UpdateEventTimeslotRequestValidator(IIdService idService) } /// Update an event timeslot. -public class UpdateEventTimeslotEndpoint(DatabaseContext databaseContext, IIdService idService) - : Endpoint +public class UpdateEventTimeslotEndpoint( + DatabaseContext databaseContext, + IRealtimeEventsService realtimeEventsService, + IIdService idService +) : Endpoint { public override void Configure() { @@ -46,7 +50,12 @@ public override async Task HandleAsync(UpdateEventTimeslotRequest req, Cancellat var timeslotId = idService.EventTimeslot.DecodeSingle(req.TimeslotId); var timeslotQuery = databaseContext.EventTimeslots.Where(x => x.Id == timeslotId); var timeslotInfo = await timeslotQuery - .Select(x => new { EventStarted = x.Event.StartedAt != null, x.EventId }) + .Select(x => new + { + EventStarted = x.Event.StartedAt != null, + x.EventId, + x.Event.Staged + }) .FirstOrDefaultAsync(ct); if (timeslotInfo == null) @@ -97,5 +106,24 @@ await this.SendErrorAsync( await updateBuilder.ExecuteAsync(ct); await SendAsync(null, cancellation: ct); + + await realtimeEventsService.SendEventAsync( + new RealtimeEvent.EventTimeslotChanged( + idService.Event.Encode(timeslotInfo.EventId), + idService.EventTimeslot.Encode(timeslotId), + RealtimeEventChangeType.Updated + ), + ct + ); + if (!timeslotInfo.Staged) + { + await realtimeEventsService.SendEventAsync( + new RealtimeEvent.PlayerEventChanged( + idService.Event.Encode(timeslotInfo.EventId), + RealtimeEventChangeType.Updated + ), + ct + ); + } } } diff --git a/src/server/host/Endpoints/Administration/Events/UpdateEventEndpoint .cs b/src/server/host/Endpoints/Administration/Events/UpdateEventEndpoint.cs similarity index 84% rename from src/server/host/Endpoints/Administration/Events/UpdateEventEndpoint .cs rename to src/server/host/Endpoints/Administration/Events/UpdateEventEndpoint.cs index 9db2f3f..ff9e9be 100644 --- a/src/server/host/Endpoints/Administration/Events/UpdateEventEndpoint .cs +++ b/src/server/host/Endpoints/Administration/Events/UpdateEventEndpoint.cs @@ -6,6 +6,7 @@ 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; @@ -24,13 +25,13 @@ public class UpdateEventRequestValidator : Validator public UpdateEventRequestValidator(IIdService idService) { RuleFor(x => x.EventId).NotEmpty().ValidSqid(idService.Event); - RuleFor(x => x.Commit); } } /// Update a new event. public class UpdateEventEndpoint( DatabaseContext databaseContext, + IRealtimeEventsService realtimeEventsService, IIdService idService, IUserPushSubscriptionMapper userPushSubscriptionMapper, IWebPushService webPushService @@ -76,6 +77,21 @@ await this.SendErrorAsync( await updateBuilder.ExecuteAsync(ct); await SendAsync(null, cancellation: ct); + await realtimeEventsService.SendEventAsync( + new RealtimeEvent.EventChanged( + idService.Event.Encode(eventId), + RealtimeEventChangeType.Updated + ), + ct + ); + await realtimeEventsService.SendEventAsync( + new RealtimeEvent.PlayerEventChanged( + idService.Event.Encode(eventId), + RealtimeEventChangeType.Created + ), + ct + ); + var pushSubscriptions = await databaseContext .UserPushSubscriptions.Where(x => x.User.Settings == null diff --git a/src/server/host/Endpoints/Administration/Maps/CreateMapEndpoint.cs b/src/server/host/Endpoints/Administration/Maps/CreateMapEndpoint.cs index 187ba37..cb29c6f 100644 --- a/src/server/host/Endpoints/Administration/Maps/CreateMapEndpoint.cs +++ b/src/server/host/Endpoints/Administration/Maps/CreateMapEndpoint.cs @@ -4,6 +4,7 @@ using MinigolfFriday.Data; using MinigolfFriday.Data.Entities; using MinigolfFriday.Domain.Models; +using MinigolfFriday.Domain.Models.RealtimeEvents; using MinigolfFriday.Host.Services; namespace MinigolfFriday.Host.Endpoints.Administration.Maps; @@ -23,8 +24,11 @@ public CreateMapRequestValidator() } /// Create a new map. -public class CreateMapEndpoint(DatabaseContext databaseContext, IIdService idService) - : Endpoint +public class CreateMapEndpoint( + DatabaseContext databaseContext, + IRealtimeEventsService realtimeEventsService, + IIdService idService +) : Endpoint { public override void Configure() { @@ -39,5 +43,13 @@ public override async Task HandleAsync(CreateMapRequest req, CancellationToken c databaseContext.Maps.Add(map); await databaseContext.SaveChangesAsync(ct); await SendAsync(new(new(idService.Map.Encode(map.Id), req.Name)), 201, ct); + + await realtimeEventsService.SendEventAsync( + new RealtimeEvent.MapChanged( + idService.Map.Encode(map.Id), + RealtimeEventChangeType.Created + ), + ct + ); } } diff --git a/src/server/host/Endpoints/Administration/Maps/DeleteMapEndpoint.cs b/src/server/host/Endpoints/Administration/Maps/DeleteMapEndpoint.cs index 296a1a6..4f120ec 100644 --- a/src/server/host/Endpoints/Administration/Maps/DeleteMapEndpoint.cs +++ b/src/server/host/Endpoints/Administration/Maps/DeleteMapEndpoint.cs @@ -3,6 +3,7 @@ using FluentValidation; using Microsoft.EntityFrameworkCore; using MinigolfFriday.Data; +using MinigolfFriday.Domain.Models.RealtimeEvents; using MinigolfFriday.Host.Common; using MinigolfFriday.Host.Services; @@ -20,8 +21,11 @@ public DeleteMapRequestValidator(IIdService idService) } /// Delete a map. -public class DeleteMapEndpoint(DatabaseContext databaseContext, IIdService idService) - : Endpoint +public class DeleteMapEndpoint( + DatabaseContext databaseContext, + IRealtimeEventsService realtimeEventsService, + IIdService idService +) : Endpoint { public override void Configure() { @@ -51,5 +55,13 @@ public override async Task HandleAsync(DeleteMapRequest req, CancellationToken c await databaseContext.SaveChangesAsync(ct); await SendOkAsync(ct); + + await realtimeEventsService.SendEventAsync( + new RealtimeEvent.MapChanged( + idService.Map.Encode(mapId), + RealtimeEventChangeType.Deleted + ), + ct + ); } } diff --git a/src/server/host/Endpoints/Administration/Maps/UpdateMapEndpoint.cs b/src/server/host/Endpoints/Administration/Maps/UpdateMapEndpoint.cs index a5371eb..ab81693 100644 --- a/src/server/host/Endpoints/Administration/Maps/UpdateMapEndpoint.cs +++ b/src/server/host/Endpoints/Administration/Maps/UpdateMapEndpoint.cs @@ -3,6 +3,7 @@ using FluentValidation; using Microsoft.EntityFrameworkCore; using MinigolfFriday.Data; +using MinigolfFriday.Domain.Models.RealtimeEvents; using MinigolfFriday.Host.Common; using MinigolfFriday.Host.Services; @@ -24,8 +25,11 @@ public UpdateMapRequestValidator(IIdService idService) } /// Update a map. -public class UpdateMapEndpoint(DatabaseContext databaseContext, IIdService idService) - : Endpoint +public class UpdateMapEndpoint( + DatabaseContext databaseContext, + IRealtimeEventsService realtimeEventsService, + IIdService idService +) : Endpoint { public override void Configure() { @@ -48,5 +52,13 @@ public override async Task HandleAsync(UpdateMapRequest req, CancellationToken c map.Name = req.Name ?? map.Name; await databaseContext.SaveChangesAsync(ct); await SendOkAsync(ct); + + await realtimeEventsService.SendEventAsync( + new RealtimeEvent.MapChanged( + idService.Map.Encode(mapId), + RealtimeEventChangeType.Updated + ), + ct + ); } } diff --git a/src/server/host/Endpoints/Administration/Users/CreateUserEndpoint.cs b/src/server/host/Endpoints/Administration/Users/CreateUserEndpoint.cs index f124fa3..544a81f 100644 --- a/src/server/host/Endpoints/Administration/Users/CreateUserEndpoint.cs +++ b/src/server/host/Endpoints/Administration/Users/CreateUserEndpoint.cs @@ -4,6 +4,7 @@ using MinigolfFriday.Data; using MinigolfFriday.Data.Entities; using MinigolfFriday.Domain.Models; +using MinigolfFriday.Domain.Models.RealtimeEvents; using MinigolfFriday.Host.Services; using MinigolfFriday.Host.Utilities; @@ -47,8 +48,11 @@ public CreateUserRequestValidator(IIdService idService) } /// Create a new user. -public class CreateUserEndpoint(DatabaseContext databaseContext, IIdService idService) - : Endpoint +public class CreateUserEndpoint( + DatabaseContext databaseContext, + IRealtimeEventsService realtimeEventsService, + IIdService idService +) : Endpoint { public override void Configure() { @@ -85,5 +89,13 @@ await SendAsync( 201, ct ); + + await realtimeEventsService.SendEventAsync( + new RealtimeEvent.UserChanged( + idService.User.Encode(user.Id), + RealtimeEventChangeType.Created + ), + ct + ); } } diff --git a/src/server/host/Endpoints/Administration/Users/DeleteUserEndpoint.cs b/src/server/host/Endpoints/Administration/Users/DeleteUserEndpoint.cs index 8a38af3..f782e24 100644 --- a/src/server/host/Endpoints/Administration/Users/DeleteUserEndpoint.cs +++ b/src/server/host/Endpoints/Administration/Users/DeleteUserEndpoint.cs @@ -3,6 +3,7 @@ using FluentValidation; using Microsoft.EntityFrameworkCore; using MinigolfFriday.Data; +using MinigolfFriday.Domain.Models.RealtimeEvents; using MinigolfFriday.Host.Common; using MinigolfFriday.Host.Services; @@ -22,6 +23,7 @@ public DeleteUserEndpointRequestValidator(IIdService idService) /// Delete a user. public class DeleteUserEndpoint( DatabaseContext databaseContext, + IRealtimeEventsService realtimeEventsService, IIdService idService, IJwtService jwtService ) : Endpoint @@ -78,5 +80,10 @@ public override async Task HandleAsync(DeleteUserEndpointRequest req, Cancellati await databaseContext.SaveChangesAsync(ct); await SendAsync(null, cancellation: ct); + + await realtimeEventsService.SendEventAsync( + new RealtimeEvent.UserChanged(req.UserId, RealtimeEventChangeType.Deleted), + ct + ); } } diff --git a/src/server/host/Endpoints/Administration/Users/UpdateUserEndpoint.cs b/src/server/host/Endpoints/Administration/Users/UpdateUserEndpoint.cs index daf7ab4..bf3e204 100644 --- a/src/server/host/Endpoints/Administration/Users/UpdateUserEndpoint.cs +++ b/src/server/host/Endpoints/Administration/Users/UpdateUserEndpoint.cs @@ -5,6 +5,7 @@ using Microsoft.EntityFrameworkCore; using MinigolfFriday.Data; using MinigolfFriday.Domain.Models; +using MinigolfFriday.Domain.Models.RealtimeEvents; using MinigolfFriday.Host.Common; using MinigolfFriday.Host.Services; @@ -59,8 +60,11 @@ public UpdateUserRequestValidator(IIdService idService) } /// Update a user. -public class UpdateUserEndpoint(DatabaseContext databaseContext, IIdService idService) - : Endpoint +public class UpdateUserEndpoint( + DatabaseContext databaseContext, + IRealtimeEventsService realtimeEventsService, + IIdService idService +) : Endpoint { public override void Configure() { @@ -130,5 +134,13 @@ public override async Task HandleAsync(UpdateUserRequest req, CancellationToken ThrowIfAnyErrors(); await databaseContext.SaveChangesAsync(ct); await SendAsync(null, cancellation: ct); + + await realtimeEventsService.SendEventAsync( + new RealtimeEvent.UserChanged( + idService.User.Encode(userId), + RealtimeEventChangeType.Updated + ), + ct + ); } } diff --git a/src/server/host/Endpoints/Events/UpdatePlayerEventRegistrationsEndpoint.cs b/src/server/host/Endpoints/Events/UpdatePlayerEventRegistrationsEndpoint.cs index 93cfb6b..8855a6e 100644 --- a/src/server/host/Endpoints/Events/UpdatePlayerEventRegistrationsEndpoint.cs +++ b/src/server/host/Endpoints/Events/UpdatePlayerEventRegistrationsEndpoint.cs @@ -5,6 +5,7 @@ using MinigolfFriday.Data; using MinigolfFriday.Data.Entities; using MinigolfFriday.Domain.Models; +using MinigolfFriday.Domain.Models.RealtimeEvents; using MinigolfFriday.Host.Common; using MinigolfFriday.Host.Services; @@ -40,6 +41,7 @@ public UpdatePlayerEventRegistrationsRequestValidator(IIdService idService) public class UpdatePlayerEventRegistrationsEndpoint( DatabaseContext databaseContext, + IRealtimeEventsService realtimeEventsService, IIdService idService, IJwtService jwtService ) : Endpoint @@ -148,5 +150,33 @@ await this.SendErrorAsync( } 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 changeEvents = registrations + .Where(x => !targetRegistrations.Any(y => y.TimeslotId == x.EventTimeslotId)) + .Select(x => new RealtimeEvent.PlayerEventTimeslotRegistrationChanged( + idService.Event.Encode(eventId), + idService.EventTimeslot.Encode(x.EventTimeslotId), + idService.User.Encode(userId), + false + )) + .Concat( + targetRegistrations + .Where(x => !registrations.Any(y => y.EventTimeslotId == x.TimeslotId)) + .Select(x => new RealtimeEvent.PlayerEventTimeslotRegistrationChanged( + idService.Event.Encode(eventId), + idService.EventTimeslot.Encode(x.TimeslotId), + idService.User.Encode(userId), + true + )) + ); + foreach (var changeEvent in changeEvents) + await realtimeEventsService.SendEventAsync(changeEvent, ct); } } diff --git a/src/server/host/Endpoints/UserSettings/UpdateUserSettingsEndpoint.cs b/src/server/host/Endpoints/UserSettings/UpdateUserSettingsEndpoint.cs index 8d386d0..f104636 100644 --- a/src/server/host/Endpoints/UserSettings/UpdateUserSettingsEndpoint.cs +++ b/src/server/host/Endpoints/UserSettings/UpdateUserSettingsEndpoint.cs @@ -3,6 +3,7 @@ using FluentValidation; using Microsoft.EntityFrameworkCore; using MinigolfFriday.Data; +using MinigolfFriday.Domain.Models.RealtimeEvents; using MinigolfFriday.Host.Common; using MinigolfFriday.Host.Services; @@ -35,8 +36,12 @@ public UpdateUserSettingsRequestValidator(IIdService idService) } } -public class UpdateUserSettingsEndpoint(DatabaseContext databaseContext, IJwtService jwtService) - : Endpoint +public class UpdateUserSettingsEndpoint( + DatabaseContext databaseContext, + IRealtimeEventsService realtimeEventsService, + IJwtService jwtService, + IIdService idService +) : Endpoint { public override void Configure() { @@ -89,5 +94,10 @@ public override async Task HandleAsync(UpdateUserSettingsRequest req, Cancellati await databaseContext.SaveChangesAsync(ct); await SendOkAsync(ct); + + await realtimeEventsService.SendEventAsync( + new RealtimeEvent.UserSettingsChanged(idService.User.Encode(userId)), + ct + ); } } diff --git a/src/server/host/Hubs/RealtimeEventsHub.cs b/src/server/host/Hubs/RealtimeEventsHub.cs new file mode 100644 index 0000000..c72b230 --- /dev/null +++ b/src/server/host/Hubs/RealtimeEventsHub.cs @@ -0,0 +1,32 @@ +using FastEnumUtility; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.SignalR; +using MinigolfFriday.Domain.Models; +using MinigolfFriday.Domain.Models.RealtimeEvents; + +namespace MinigolfFriday.Host.Hubs; + +[Authorize] +public class RealtimeEventsHub : Hub +{ + public override async Task OnConnectedAsync() + { + if (Context.User?.IsInRole(FastEnum.GetName(Role.Admin)!) == true) + { + await Groups.AddToGroupAsync( + Context.ConnectionId, + FastEnum.GetName(RealtimeEventGroup.Admin)! + ); + } + + if (Context.User?.IsInRole(FastEnum.GetName(Role.Player)!) == true) + { + await Groups.AddToGroupAsync( + Context.ConnectionId, + FastEnum.GetName(RealtimeEventGroup.Player)! + ); + } + + await base.OnConnectedAsync(); + } +} diff --git a/src/server/host/Options/ConfigureJwtBearerOptions.cs b/src/server/host/Options/ConfigureJwtBearerOptions.cs index 57b411c..afb9e79 100644 --- a/src/server/host/Options/ConfigureJwtBearerOptions.cs +++ b/src/server/host/Options/ConfigureJwtBearerOptions.cs @@ -28,6 +28,18 @@ public void Configure(string? name, JwtBearerOptions options) ValidateLifetime = true, ClockSkew = TimeSpan.FromMinutes(1) }; + options.Events = new JwtBearerEvents + { + OnMessageReceived = context => + { + var accessToken = context.Request.Query["access_token"]; + if (!string.IsNullOrEmpty(accessToken)) + { + context.Token = accessToken; + } + return Task.CompletedTask; + } + }; } public void Configure(JwtBearerOptions options) diff --git a/src/server/host/Program.cs b/src/server/host/Program.cs index 25074a1..5668190 100644 --- a/src/server/host/Program.cs +++ b/src/server/host/Program.cs @@ -7,6 +7,7 @@ using MinigolfFriday.Data; using MinigolfFriday.Domain.Extensions; using MinigolfFriday.Domain.Options; +using MinigolfFriday.Host.Hubs; using MinigolfFriday.Host.Mappers; using MinigolfFriday.Host.Middlewares; using MinigolfFriday.Host.Options; @@ -36,8 +37,9 @@ builder.Services.AddDbContext(); builder.Services.AddHttpClient(); -builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); @@ -55,6 +57,11 @@ endpointTypes = endpointTypes.Where(x => !x.FullName!.Contains(".Dev.")); o.SourceGeneratorDiscoveredTypes.AddRange(endpointTypes); }); +builder + .Services.AddSignalR() + .AddJsonProtocol(options => + configureJsonSerializerOptions.Configure(options.PayloadSerializerOptions) + ); builder.Services.AddEndpointsApiExplorer(); builder.Services.SwaggerDocument(c => { @@ -101,6 +108,7 @@ app.UseAuthorization(); app.MapHealthChecks("/healthz"); +app.MapHub("/hubs/realtime-events"); app.UseFastEndpoints(new ConfigureFastEndpointsConfig(configureJsonSerializerOptions).Configure) .UseSwaggerGen(); diff --git a/src/server/host/Services/RealtimeEventsService.cs b/src/server/host/Services/RealtimeEventsService.cs new file mode 100644 index 0000000..d870b7b --- /dev/null +++ b/src/server/host/Services/RealtimeEventsService.cs @@ -0,0 +1,32 @@ +using FastEnumUtility; +using Microsoft.AspNetCore.SignalR; +using MinigolfFriday.Domain.Models.RealtimeEvents; +using MinigolfFriday.Host.Hubs; + +namespace MinigolfFriday.Host.Services; + +[GenerateAutoInterface] +public class RealtimeEventsService(IHubContext hubContext) + : IRealtimeEventsService +{ + public async Task SendEventAsync(T @event, CancellationToken ct = default) + where T : IRealtimeEvent + { + if (@event is IGroupRealtimeEvent groupEvent) + { + var group = + groupEvent.Group == RealtimeEventGroup.All + ? hubContext.Clients.All + : hubContext.Clients.Group(FastEnum.GetName(groupEvent.Group)!); + await group.SendAsync(T.MethodName, groupEvent, ct); + } + else if (@event is IUserRealtimeEvent userEvent) + { + await hubContext.Clients.User(userEvent.UserId).SendAsync(T.MethodName, userEvent, ct); + } + else + { + throw new ArgumentException("Event must be either a group or user event."); + } + } +}