/** * 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 { RoomsActions } from '../rooms/rooms.actions'; import { selectAllUsers, selectCurrentUser, selectCurrentUserId, selectHostId } from './users.selectors'; import { selectCurrentRoom, selectSavedRooms } from '../rooms/rooms.selectors'; import { RealtimeSessionFacade } from '../../core/realtime'; import { DatabaseService } from '../../infrastructure/persistence'; import { ServerDirectoryFacade } from '../../domains/server-directory'; import { canManageMember, resolveLegacyRole, resolveRoomPermission } from '../../domains/access-control'; import { BanEntry, ChatEvent, Room, User } from '../../shared-kernel'; import { findRoomMember, removeRoomMember } from '../rooms/room-members.helpers'; type IncomingModerationExtraAction = | ReturnType | ReturnType | ReturnType; type IncomingModerationAction = | ReturnType | IncomingModerationExtraAction; @Injectable() export class UsersEffects { private actions$ = inject(Actions); private store = inject(Store); private db = inject(DatabaseService); private serverDirectory = inject(ServerDirectoryFacade); private webrtc = inject(RealtimeSessionFacade); // 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), this.store.select(selectSavedRooms) ), mergeMap(([ { userId, roomId }, currentUser, currentRoom, savedRooms ]) => { if (!currentUser) return EMPTY; const room = this.resolveRoom(roomId, currentRoom, savedRooms); if (!room) return EMPTY; const canKick = this.canKickInRoom(room, currentUser, currentRoom, userId); if (!canKick) return EMPTY; const nextMembers = removeRoomMember(room.members ?? [], userId, userId); return this.serverDirectory.kickServerMember( room.id, { actorUserId: currentUser.id, actorRole: currentUser.role, targetUserId: userId }, this.toSourceSelector(room) ).pipe( catchError((error) => { console.error('Failed to revoke server membership on kick:', error); return of(void 0); }), mergeMap(() => { this.webrtc.broadcastMessage({ type: 'kick', targetUserId: userId, roomId: room.id, kickedBy: currentUser.id }); return currentRoom?.id === room.id ? [ RoomsActions.updateRoom({ roomId: room.id, changes: { members: nextMembers } }), UsersActions.kickUserSuccess({ userId, roomId: room.id }) ] : of( RoomsActions.updateRoom({ roomId: room.id, changes: { members: nextMembers } }) ); }) ); }) ) ); /** 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), this.store.select(selectSavedRooms), this.store.select(selectAllUsers) ), mergeMap(([ { userId, roomId, displayName, reason, expiresAt }, currentUser, currentRoom, savedRooms, allUsers ]) => { if (!currentUser) return EMPTY; const room = this.resolveRoom(roomId, currentRoom, savedRooms); if (!room) return EMPTY; const canBan = this.canBanInRoom(room, currentUser, currentRoom, userId); if (!canBan) return EMPTY; const targetUser = allUsers.find((user) => user.id === userId || user.oderId === userId); const targetMember = findRoomMember(room.members ?? [], userId); const nextMembers = removeRoomMember(room.members ?? [], userId, userId); const ban: BanEntry = { oderId: uuidv4(), userId, roomId: room.id, bannedBy: currentUser.id, displayName: displayName || targetUser?.displayName || targetMember?.displayName, reason, expiresAt, timestamp: Date.now() }; return this.serverDirectory.banServerMember( room.id, { actorUserId: currentUser.id, actorRole: currentUser.role, targetUserId: userId, banId: ban.oderId, displayName: ban.displayName, reason, expiresAt }, this.toSourceSelector(room) ).pipe( catchError((error) => { console.error('Failed to persist server ban:', error); return of(void 0); }), switchMap(() => from(this.db.saveBan(ban)).pipe( tap(() => { this.webrtc.broadcastMessage({ type: 'ban', targetUserId: userId, roomId: room.id, bannedBy: currentUser.id, ban }); }), mergeMap(() => { const actions: (ReturnType | ReturnType)[] = [ RoomsActions.updateRoom({ roomId: room.id, changes: { members: nextMembers } }) ]; if (currentRoom?.id === room.id) { actions.push(UsersActions.banUserSuccess({ userId, roomId: room.id, ban })); } return actions; }), catchError(() => EMPTY) ) ) ); }) ) ); /** Removes a ban entry locally and broadcasts the change to peers in the same room. */ unbanUser$ = createEffect(() => this.actions$.pipe( ofType(UsersActions.unbanUser), withLatestFrom( this.store.select(selectCurrentUser), this.store.select(selectCurrentRoom), this.store.select(selectSavedRooms) ), switchMap(([ { roomId, oderId }, currentUser, currentRoom, savedRooms ]) => { const room = this.resolveRoom(roomId, currentRoom, savedRooms); if (!currentUser || !room || !this.canModerateRoom(room, currentUser, currentRoom)) return EMPTY; return this.serverDirectory.unbanServerMember( room.id, { actorUserId: currentUser.id, actorRole: currentUser.role, banId: oderId }, this.toSourceSelector(room) ).pipe( catchError((error) => { console.error('Failed to remove server ban:', error); return of(void 0); }), switchMap(() => from(this.db.removeBan(oderId)).pipe( tap(() => { this.webrtc.broadcastMessage({ type: 'unban', roomId: room.id, banOderId: oderId }); }), 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: [] }))) ); }) ) ); /** Applies incoming moderation events from peers to local persistence and UI state. */ incomingModerationEvents$ = createEffect(() => this.webrtc.onMessageReceived.pipe( withLatestFrom( this.store.select(selectCurrentUser), this.store.select(selectCurrentRoom), this.store.select(selectSavedRooms) ), mergeMap(([ event, currentUser, currentRoom, savedRooms ]) => { switch (event.type) { case 'kick': return this.handleIncomingKick(event, currentUser ?? null, currentRoom, savedRooms); case 'ban': return this.handleIncomingBan(event, currentUser ?? null, currentRoom, savedRooms); case 'unban': return this.handleIncomingUnban(event, currentRoom, savedRooms); default: return EMPTY; } }) ) ); /** 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 } ); /** Keep signaling identity aligned with the current profile to avoid stale fallback names. */ syncSignalingIdentity$ = createEffect( () => this.actions$.pipe( ofType( UsersActions.setCurrentUser, UsersActions.loadCurrentUserSuccess ), withLatestFrom(this.store.select(selectCurrentUser)), tap(([, user]) => { if (!user) return; this.webrtc.identify(user.oderId || user.id, this.resolveDisplayName(user)); }) ), { dispatch: false } ); private resolveRoom(roomId: string | undefined, currentRoom: Room | null, savedRooms: Room[]): Room | null { if (!roomId) return currentRoom; if (currentRoom?.id === roomId) return currentRoom; return savedRooms.find((room) => room.id === roomId) ?? null; } private resolveDisplayName(user: Pick): string { const displayName = user.displayName?.trim(); if (displayName) { return displayName; } return user.username?.trim() || 'User'; } private toSourceSelector(room: Room): { sourceId?: string; sourceUrl?: string } { return { sourceId: room.sourceId, sourceUrl: room.sourceUrl }; } private canModerateRoom(room: Room, currentUser: User, currentRoom: Room | null): boolean { const role = this.getCurrentUserRoleForRoom(room, currentUser, currentRoom); return role === 'host' || resolveRoomPermission(room, currentUser, 'manageBans'); } private canKickInRoom(room: Room, currentUser: User, currentRoom: Room | null, targetUserId: string): boolean { return canManageMember(room, currentUser, findRoomMember(room.members ?? [], targetUserId) ?? { id: targetUserId }, 'kickMembers'); } private canBanInRoom(room: Room, currentUser: User, currentRoom: Room | null, targetUserId: string): boolean { return canManageMember(room, currentUser, findRoomMember(room.members ?? [], targetUserId) ?? { id: targetUserId }, 'banMembers'); } private getCurrentUserRoleForRoom(room: Room, currentUser: User, currentRoom: Room | null): User['role'] | null { return resolveLegacyRole(currentRoom?.id === room.id ? currentRoom : room, currentUser); } private removeMemberFromRoom(room: Room, targetUserId: string): Partial { return { members: removeRoomMember(room.members ?? [], targetUserId, targetUserId) }; } private resolveIncomingModerationActions( room: Room, targetUserId: string, currentRoom: Room | null, extra: IncomingModerationExtraAction[] = [] ) { const actions: IncomingModerationAction[] = [ RoomsActions.updateRoom({ roomId: room.id, changes: this.removeMemberFromRoom(room, targetUserId) }) ]; if (currentRoom?.id === room.id) { actions.push(...extra); } else { actions.push(...extra.filter((action) => action.type === RoomsActions.forgetRoom.type)); } return actions; } private shouldAffectVisibleUsers(room: Room, currentRoom: Room | null): boolean { return currentRoom?.id === room.id; } private canForgetForTarget( targetUserId: string, currentUser: User | null ): ReturnType | null { return this.isCurrentUserTarget(targetUserId, currentUser) ? RoomsActions.forgetRoom({ roomId: '' }) : null; } private isCurrentUserTarget(targetUserId: string, currentUser: User | null): boolean { return !!currentUser && (targetUserId === currentUser.id || targetUserId === currentUser.oderId); } private buildIncomingBan(event: ChatEvent, targetUserId: string, roomId: string): BanEntry { const payloadBan = event.ban && typeof event.ban === 'object' ? event.ban as Partial : null; return { oderId: typeof payloadBan?.oderId === 'string' ? payloadBan.oderId : uuidv4(), userId: typeof payloadBan?.userId === 'string' ? payloadBan.userId : targetUserId, roomId, bannedBy: typeof payloadBan?.bannedBy === 'string' ? payloadBan.bannedBy : (typeof event.bannedBy === 'string' ? event.bannedBy : 'unknown'), displayName: typeof payloadBan?.displayName === 'string' ? payloadBan.displayName : (typeof event.displayName === 'string' ? event.displayName : undefined), reason: typeof payloadBan?.reason === 'string' ? payloadBan.reason : (typeof event.reason === 'string' ? event.reason : undefined), expiresAt: typeof payloadBan?.expiresAt === 'number' ? payloadBan.expiresAt : (typeof event.expiresAt === 'number' ? event.expiresAt : undefined), timestamp: typeof payloadBan?.timestamp === 'number' ? payloadBan.timestamp : Date.now() }; } private handleIncomingKick( event: ChatEvent, currentUser: User | null, currentRoom: Room | null, savedRooms: Room[] ) { const roomId = typeof event.roomId === 'string' ? event.roomId : currentRoom?.id; const room = this.resolveRoom(roomId, currentRoom, savedRooms); const targetUserId = typeof event.targetUserId === 'string' ? event.targetUserId : ''; if (!room || !targetUserId) return EMPTY; const actions = this.resolveIncomingModerationActions( room, targetUserId, currentRoom, this.isCurrentUserTarget(targetUserId, currentUser) ? [RoomsActions.forgetRoom({ roomId: room.id })] : [ UsersActions.kickUserSuccess({ userId: targetUserId, roomId: room.id }) ] ); return actions; } private handleIncomingBan( event: ChatEvent, currentUser: User | null, currentRoom: Room | null, savedRooms: Room[] ) { const roomId = typeof event.roomId === 'string' ? event.roomId : currentRoom?.id; const room = this.resolveRoom(roomId, currentRoom, savedRooms); const targetUserId = typeof event.targetUserId === 'string' ? event.targetUserId : ''; if (!room || !targetUserId) return EMPTY; const ban = this.buildIncomingBan(event, targetUserId, room.id); const actions = this.resolveIncomingModerationActions( room, targetUserId, currentRoom, this.isCurrentUserTarget(targetUserId, currentUser) ? [RoomsActions.forgetRoom({ roomId: room.id })] : [ UsersActions.banUserSuccess({ userId: targetUserId, roomId: room.id, ban }) ] ); return from(this.db.saveBan(ban)).pipe( mergeMap(() => (actions.length > 0 ? actions : EMPTY)), catchError(() => EMPTY) ); } private handleIncomingUnban(event: ChatEvent, currentRoom: Room | null, savedRooms: Room[]) { const roomId = typeof event.roomId === 'string' ? event.roomId : currentRoom?.id; const room = this.resolveRoom(roomId, currentRoom, savedRooms); const banOderId = typeof event.banOderId === 'string' ? event.banOderId : (typeof event.oderId === 'string' ? event.oderId : ''); if (!room || !banOderId) return EMPTY; return from(this.db.removeBan(banOderId)).pipe( mergeMap(() => (currentRoom?.id === room.id ? of(UsersActions.unbanUserSuccess({ oderId: banOderId })) : EMPTY)), catchError(() => EMPTY) ); } }