/* 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, EMPTY } from 'rxjs'; import { mergeMap, withLatestFrom, tap, catchError } from 'rxjs/operators'; import { RoomsActions } from './rooms.actions'; import { selectCurrentUser } from '../users/users.selectors'; import { selectCurrentRoom, selectSavedRooms } from './rooms.selectors'; import { RealtimeSessionFacade } from '../../core/realtime'; import { DatabaseService } from '../../infrastructure/persistence'; import { ServerDirectoryFacade } from '../../domains/server-directory'; import { normalizeRoomAccessControl, resolveRoomPermission, withLegacyRoomPermissions } from '../../domains/access-control'; import { Room, RoomSettings } from '../../shared-kernel'; import { resolveRoom, getUserRoleForRoom, canManageChannelsInRoom } from './rooms.helpers'; import { defaultChannels } from './room-channels.defaults'; /** * NgRx effects for room settings, permissions, channels, and icon updates. */ @Injectable() export class RoomSettingsEffects { private actions$ = inject(Actions); private store = inject(Store); private webrtc = inject(RealtimeSessionFacade); private db = inject(DatabaseService); private serverDirectory = inject(ServerDirectoryFacade); /** Updates room settings (host/admin-only) and broadcasts changes to all peers. */ updateRoomSettings$ = createEffect(() => this.actions$.pipe( ofType(RoomsActions.updateRoomSettings), withLatestFrom( this.store.select(selectCurrentUser), this.store.select(selectCurrentRoom), this.store.select(selectSavedRooms) ), mergeMap(([ { roomId, settings }, currentUser, currentRoom, savedRooms ]) => { if (!currentUser) return of(RoomsActions.updateRoomSettingsFailure({ error: 'Not logged in' })); const room = resolveRoom(roomId, currentRoom, savedRooms); if (!room) return of(RoomsActions.updateRoomSettingsFailure({ error: 'Room not found' })); const currentUserRole = getUserRoleForRoom(room, currentUser, currentRoom); const isOwner = currentUserRole === 'host'; const canManageRoom = isOwner || resolveRoomPermission(room, currentUser, 'manageServer'); if (!canManageRoom) { return of( RoomsActions.updateRoomSettingsFailure({ error: 'Permission denied' }) ); } const hasPasswordUpdate = Object.prototype.hasOwnProperty.call(settings, 'password'); const normalizedPassword = typeof settings.password === 'string' ? settings.password.trim() : undefined; const nextHasPassword = typeof settings.hasPassword === 'boolean' ? settings.hasPassword : (hasPasswordUpdate ? !!normalizedPassword : (typeof room.hasPassword === 'boolean' ? room.hasPassword : !!room.password)); const updatedSettings: RoomSettings = { name: settings.name ?? room.name, description: settings.description ?? room.description, topic: settings.topic ?? room.topic, isPrivate: settings.isPrivate ?? room.isPrivate, password: hasPasswordUpdate ? (normalizedPassword || '') : room.password, hasPassword: nextHasPassword, maxUsers: settings.maxUsers ?? room.maxUsers }; const localRoomUpdates: Partial = { ...updatedSettings, password: hasPasswordUpdate ? (normalizedPassword || undefined) : room.password, hasPassword: nextHasPassword }; const sharedSettings: RoomSettings = { name: updatedSettings.name, description: updatedSettings.description, topic: updatedSettings.topic, isPrivate: updatedSettings.isPrivate, hasPassword: nextHasPassword, maxUsers: updatedSettings.maxUsers, password: hasPasswordUpdate ? (normalizedPassword || '') : undefined }; this.db.updateRoom(room.id, localRoomUpdates); this.webrtc.broadcastMessage({ type: 'room-settings-update', roomId: room.id, settings: sharedSettings }); if (canManageRoom) { this.serverDirectory.updateServer(room.id, { currentOwnerId: currentUser.id, actingRole: currentUserRole ?? undefined, name: updatedSettings.name, description: updatedSettings.description, isPrivate: updatedSettings.isPrivate, maxUsers: updatedSettings.maxUsers, password: hasPasswordUpdate ? (normalizedPassword || null) : undefined }, { sourceId: room.sourceId, sourceUrl: room.sourceUrl }).subscribe({ error: () => {} }); } return of(RoomsActions.updateRoomSettingsSuccess({ roomId: room.id, settings: updatedSettings })); }), catchError((error) => of(RoomsActions.updateRoomSettingsFailure({ error: error.message }))) ) ); /** Persists and broadcasts channel add/remove/rename changes. */ syncChannelChanges$ = createEffect( () => this.actions$.pipe( ofType(RoomsActions.addChannel, RoomsActions.removeChannel, RoomsActions.renameChannel), withLatestFrom( this.store.select(selectCurrentUser), this.store.select(selectCurrentRoom) ), tap(([ , currentUser, currentRoom ]) => { if (!currentUser || !currentRoom) { return; } const role = getUserRoleForRoom(currentRoom, currentUser, currentRoom); if (!canManageChannelsInRoom(currentRoom, currentUser, currentRoom, role)) { return; } const channels = currentRoom.channels ?? defaultChannels(); this.db.updateRoom(currentRoom.id, { channels }); this.webrtc.broadcastMessage({ type: 'channels-update', roomId: currentRoom.id, channels }); this.serverDirectory.updateServer(currentRoom.id, { currentOwnerId: currentUser.id, actingRole: role ?? undefined, channels }, { sourceId: currentRoom.sourceId, sourceUrl: currentRoom.sourceUrl }).subscribe({ error: () => {} }); }) ), { dispatch: false } ); /** Updates room permission grants (host-only) and broadcasts to peers. */ updateRoomPermissions$ = createEffect(() => this.actions$.pipe( ofType(RoomsActions.updateRoomPermissions), withLatestFrom( this.store.select(selectCurrentUser), this.store.select(selectCurrentRoom), this.store.select(selectSavedRooms) ), mergeMap(([ { roomId, permissions }, currentUser, currentRoom, savedRooms ]) => { if (!currentUser) return EMPTY; const room = resolveRoom(roomId, currentRoom, savedRooms); if (!room) return EMPTY; const nextRoom = withLegacyRoomPermissions(room, permissions); return of(RoomsActions.updateRoomAccessControl({ roomId: room.id, changes: { roles: nextRoom.roles, roleAssignments: nextRoom.roleAssignments, channelPermissions: nextRoom.channelPermissions, slowModeInterval: nextRoom.slowModeInterval } })); }) ) ); /** Updates role-based access control and broadcasts to peers and directory. */ updateRoomAccessControl$ = createEffect(() => this.actions$.pipe( ofType(RoomsActions.updateRoomAccessControl), withLatestFrom( this.store.select(selectCurrentUser), this.store.select(selectCurrentRoom), this.store.select(selectSavedRooms) ), mergeMap(([ { roomId, changes }, currentUser, currentRoom, savedRooms ]) => { if (!currentUser) return EMPTY; const room = resolveRoom(roomId, currentRoom, savedRooms); if (!room) return EMPTY; const requiresRoleManagement = !!changes.roles || !!changes.roleAssignments || !!changes.channelPermissions; const requiresServerManagement = typeof changes.slowModeInterval === 'number'; const isOwner = room.hostId === currentUser.id || room.hostId === currentUser.oderId; const canManageRoles = isOwner || resolveRoomPermission(room, currentUser, 'manageRoles'); const canManageServer = isOwner || resolveRoomPermission(room, currentUser, 'manageServer'); if ((requiresRoleManagement && !canManageRoles) || (requiresServerManagement && !canManageServer)) { return EMPTY; } const nextRoom = normalizeRoomAccessControl({ ...room, ...changes }); const nextChanges: Partial = { roles: nextRoom.roles, roleAssignments: nextRoom.roleAssignments, channelPermissions: nextRoom.channelPermissions, slowModeInterval: nextRoom.slowModeInterval, permissions: nextRoom.permissions, members: nextRoom.members }; this.webrtc.broadcastMessage({ type: 'room-permissions-update', roomId: room.id, permissions: nextRoom.permissions, room: { roles: nextRoom.roles, roleAssignments: nextRoom.roleAssignments, channelPermissions: nextRoom.channelPermissions, slowModeInterval: nextRoom.slowModeInterval } }); this.serverDirectory.updateServer(room.id, { currentOwnerId: currentUser.id, roles: nextRoom.roles, roleAssignments: nextRoom.roleAssignments, channelPermissions: nextRoom.channelPermissions, slowModeInterval: nextRoom.slowModeInterval }, { sourceId: room.sourceId, sourceUrl: room.sourceUrl }).subscribe({ error: () => {} }); return of(RoomsActions.updateRoom({ roomId: room.id, changes: nextChanges })); }) ) ); /** Updates the server icon (permission-enforced) and broadcasts to peers. */ updateServerIcon$ = createEffect(() => this.actions$.pipe( ofType(RoomsActions.updateServerIcon), withLatestFrom( this.store.select(selectCurrentUser), this.store.select(selectCurrentRoom), this.store.select(selectSavedRooms) ), mergeMap(([ { roomId, icon }, currentUser, currentRoom, savedRooms ]) => { if (!currentUser) { return of(RoomsActions.updateServerIconFailure({ error: 'Not logged in' })); } const room = resolveRoom(roomId, currentRoom, savedRooms); if (!room) { return of(RoomsActions.updateServerIconFailure({ error: 'Room not found' })); } const isOwner = room.hostId === currentUser.id || room.hostId === currentUser.oderId; const canByRole = resolveRoomPermission(room, currentUser, 'manageIcon'); if (!isOwner && !canByRole) { return of(RoomsActions.updateServerIconFailure({ error: 'Permission denied' })); } const iconUpdatedAt = Date.now(); const changes: Partial = { icon, iconUpdatedAt }; this.db.updateRoom(room.id, changes); this.webrtc.broadcastMessage({ type: 'server-icon-update', roomId: room.id, icon, iconUpdatedAt }); this.webrtc.sendRawMessage({ type: 'server_icon_available', serverId: room.id, iconUpdatedAt }); this.serverDirectory.updateServer(room.id, { currentOwnerId: currentUser.id, actingRole: isOwner ? 'host' : undefined, icon, iconUpdatedAt }, { sourceId: room.sourceId, sourceUrl: room.sourceUrl }).subscribe({ error: () => {} }); return of(RoomsActions.updateServerIconSuccess({ roomId: room.id, icon, iconUpdatedAt })); }) ) ); }