/** * Users store effects (load, kick, ban, host election, profile persistence). */ /* eslint-disable @typescript-eslint/member-ordering */ import { Injectable, inject } from '@angular/core'; import { Actions, createEffect, ofType } from '@ngrx/effects'; import { Store } from '@ngrx/store'; import { of, from, EMPTY } from 'rxjs'; import { map, mergeMap, catchError, withLatestFrom, tap, switchMap } from 'rxjs/operators'; import { v4 as uuidv4 } from 'uuid'; import { UsersActions } from './users.actions'; import { selectCurrentUser, selectCurrentUserId, selectHostId } from './users.selectors'; import { selectCurrentRoom } from '../rooms/rooms.selectors'; import { DatabaseService } from '../../core/services/database.service'; import { WebRTCService } from '../../core/services/webrtc.service'; import { BanEntry, User } from '../../core/models/index'; @Injectable() export class UsersEffects { private actions$ = inject(Actions); private store = inject(Store); private db = inject(DatabaseService); private webrtc = inject(WebRTCService); // Load current user from storage /** Loads the persisted current user from the local database on startup. */ loadCurrentUser$ = createEffect(() => this.actions$.pipe( ofType(UsersActions.loadCurrentUser), switchMap(() => from(this.db.getCurrentUser()).pipe( switchMap((user) => { if (!user) { return of(UsersActions.loadCurrentUserFailure({ error: 'No current user' })); } const sanitizedUser = this.clearStartupVoiceConnection(user); if (sanitizedUser === user) { return of(UsersActions.loadCurrentUserSuccess({ user })); } return from(this.db.updateUser(user.id, { voiceState: sanitizedUser.voiceState })).pipe( map(() => UsersActions.loadCurrentUserSuccess({ user: sanitizedUser })), // If persistence fails, still load a sanitized in-memory user to keep UI correct. catchError(() => of(UsersActions.loadCurrentUserSuccess({ user: sanitizedUser }))) ); }), catchError((error) => of(UsersActions.loadCurrentUserFailure({ error: error.message })) ) ) ) ) ); private clearStartupVoiceConnection(user: User): User { const voiceState = user.voiceState; if (!voiceState) return user; const hasStaleConnectionState = voiceState.isConnected || voiceState.isSpeaking || voiceState.roomId !== undefined || voiceState.serverId !== undefined; if (!hasStaleConnectionState) return user; return { ...user, voiceState: { ...voiceState, isConnected: false, isSpeaking: false, roomId: undefined, serverId: undefined } }; } /** Loads all users associated with a specific room from the local database. */ loadRoomUsers$ = createEffect(() => this.actions$.pipe( ofType(UsersActions.loadRoomUsers), switchMap(({ roomId }) => from(this.db.getUsersByRoom(roomId)).pipe( map((users) => UsersActions.loadRoomUsersSuccess({ users })), catchError((error) => of(UsersActions.loadRoomUsersFailure({ error: error.message })) ) ) ) ) ); /** Kicks a user from the room (requires moderator+ role). Broadcasts a kick signal. */ kickUser$ = createEffect(() => this.actions$.pipe( ofType(UsersActions.kickUser), withLatestFrom( this.store.select(selectCurrentUser), this.store.select(selectCurrentRoom) ), mergeMap(([ { userId }, currentUser, currentRoom ]) => { if (!currentUser || !currentRoom) return EMPTY; const canKick = currentUser.role === 'host' || currentUser.role === 'admin' || currentUser.role === 'moderator'; if (!canKick) return EMPTY; this.webrtc.broadcastMessage({ type: 'kick', targetUserId: userId, roomId: currentRoom.id, kickedBy: currentUser.id }); return of(UsersActions.kickUserSuccess({ userId })); }) ) ); /** Bans a user, persists the ban locally, and broadcasts a ban signal to peers. */ banUser$ = createEffect(() => this.actions$.pipe( ofType(UsersActions.banUser), withLatestFrom( this.store.select(selectCurrentUser), this.store.select(selectCurrentRoom) ), mergeMap(([ { userId, reason, expiresAt }, currentUser, currentRoom ]) => { if (!currentUser || !currentRoom) return EMPTY; const canBan = currentUser.role === 'host' || currentUser.role === 'admin'; if (!canBan) return EMPTY; const ban: BanEntry = { oderId: uuidv4(), userId, roomId: currentRoom.id, bannedBy: currentUser.id, reason, expiresAt, timestamp: Date.now() }; this.db.saveBan(ban); this.webrtc.broadcastMessage({ type: 'ban', targetUserId: userId, roomId: currentRoom.id, bannedBy: currentUser.id, reason }); return of(UsersActions.banUserSuccess({ userId, ban })); }) ) ); /** Removes a ban entry from the local database. */ unbanUser$ = createEffect(() => this.actions$.pipe( ofType(UsersActions.unbanUser), switchMap(({ oderId }) => from(this.db.removeBan(oderId)).pipe( map(() => UsersActions.unbanUserSuccess({ oderId })), catchError(() => EMPTY) ) ) ) ); /** Loads all active bans for the current room from the local database. */ loadBans$ = createEffect(() => this.actions$.pipe( ofType(UsersActions.loadBans), withLatestFrom(this.store.select(selectCurrentRoom)), switchMap(([, currentRoom]) => { if (!currentRoom) { return of(UsersActions.loadBansSuccess({ bans: [] })); } return from(this.db.getBansForRoom(currentRoom.id)).pipe( map((bans) => UsersActions.loadBansSuccess({ bans })), catchError(() => of(UsersActions.loadBansSuccess({ bans: [] }))) ); }) ) ); /** Elects the current user as host if the previous host leaves. */ handleHostLeave$ = createEffect(() => this.actions$.pipe( ofType(UsersActions.userLeft), withLatestFrom( this.store.select(selectHostId), this.store.select(selectCurrentUserId) ), mergeMap(([ { userId }, hostId, currentUserId ]) => userId === hostId && currentUserId ? of(UsersActions.updateHost({ userId: currentUserId })) : EMPTY ) ) ); /** Persists user profile changes to the local database whenever the current user is updated. */ persistUser$ = createEffect( () => this.actions$.pipe( ofType( UsersActions.setCurrentUser, UsersActions.loadCurrentUserSuccess, UsersActions.updateCurrentUser ), withLatestFrom(this.store.select(selectCurrentUser)), tap(([, user]) => { if (user) { this.db.saveUser(user); // Ensure current user ID is persisted when explicitly set this.db.setCurrentUserId(user.id); } }) ), { dispatch: false } ); }