Files
Toju/toju-app/src/app/store/rooms/room-settings.effects.ts

376 lines
12 KiB
TypeScript

/* 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<Room> = {
...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<Room> = {
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<Room> = { 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 }));
})
)
);
}