diff --git a/client/src/data/rooms.ts b/client/src/data/rooms.ts index 845b4aa7..494acd25 100644 --- a/client/src/data/rooms.ts +++ b/client/src/data/rooms.ts @@ -6,6 +6,7 @@ export type Room = { id: string ownerId: string name: string + joinPolicy: string } export type RoomResponse = { diff --git a/client/src/features/room/organisms/ChatBox.tsx b/client/src/features/room/organisms/ChatBox.tsx index e5466702..38493869 100644 --- a/client/src/features/room/organisms/ChatBox.tsx +++ b/client/src/features/room/organisms/ChatBox.tsx @@ -7,28 +7,40 @@ import { useUserDetails } from '../../../data/users' export type ChatBoxProps = { roomId: string + canJoin: boolean } -const JoinAlert = ({ roomId }: ChatBoxProps) => { +const JoinAlert = ({ roomId, canJoin }: ChatBoxProps) => { const { mutate: joinRoom, isLoading, isSuccess: isJoined } = useJoinRoom(roomId) + useEffect(() => { if (isJoined) { window.location.reload() } }, [isJoined]) + + if (canJoin) { + return ( + + + You need to join this room to chat. + + + + ) + } + return ( - You need to join this room to chat. - - + You need an invite to join. ) } -export const ChatBox: React.FC = ({ roomId }: ChatBoxProps) => { +export const ChatBox: React.FC = ({ roomId, canJoin }: ChatBoxProps) => { const [content, setContent] = useState('') const { data: user, isLoading } = useUserDetails() const joined = user?.rooms.some((room) => room.id === roomId) @@ -54,8 +66,9 @@ export const ChatBox: React.FC = ({ roomId }: ChatBoxProps) => { } } + console.info({ joined }) if (!isLoading && !joined) { - return + return } return ( diff --git a/client/src/features/room/pages/Room.tsx b/client/src/features/room/pages/Room.tsx index f4b24df0..fa4a0f6c 100644 --- a/client/src/features/room/pages/Room.tsx +++ b/client/src/features/room/pages/Room.tsx @@ -14,19 +14,20 @@ type Params = { export const RoomPage = () => { const { roomId } = useParams() as Params - const { data: roomResponse, isLoading: isLoadingRoom } = useRoom(roomId) + const { data: roomResponse } = useRoom(roomId) const canRead = can('read', roomResponse) + const canJoin = can('join', roomResponse) const { data: messages, isLoading: isLoadingMessages } = useMessages(roomId, { enabled: canRead }) useMessagesSubscription(roomId, { enabled: canRead }) - if ((canRead && isLoadingMessages) || isLoadingRoom) return + if ((canRead && isLoadingMessages) || !roomResponse) return return ( {} - + ) } diff --git a/services/api/src/app/messages/command.service.ts b/services/api/src/app/messages/command.service.ts index 181a3cff..6f4f7028 100644 --- a/services/api/src/app/messages/command.service.ts +++ b/services/api/src/app/messages/command.service.ts @@ -7,6 +7,7 @@ import { RenameUserUseCase } from '@usecases/users/rename'; import { Dispatcher } from '@entities/messages'; import { LoremCommandUseCase } from '@usecases/commands/lorem'; import { ParseCommandUseCase } from '@usecases/commands/parse'; +import { ChangeRoomJoinPolicyUseCase } from '@usecases/rooms/change-room-join-policy'; @Injectable() export class CommandService { @@ -16,6 +17,7 @@ export class CommandService { private readonly lorem: LoremCommandUseCase, private readonly help: HelpCommandUseCase, private readonly parse: ParseCommandUseCase, + private readonly changeRoomJoinPolicy: ChangeRoomJoinPolicyUseCase, readonly dispatcher: Dispatcher, ) {} @@ -35,6 +37,12 @@ export class CommandService { case 'lorem': await this.lorem.exec({ ...params, roomId, authenticatedUser }); break; + case 'changeRoomJoinPolicy': + await this.changeRoomJoinPolicy.exec({ + ...params, + roomId, + authenticatedUser, + }); } } } diff --git a/services/api/src/app/messages/messages.module.ts b/services/api/src/app/messages/messages.module.ts index 44140b93..4065e3dd 100644 --- a/services/api/src/app/messages/messages.module.ts +++ b/services/api/src/app/messages/messages.module.ts @@ -12,6 +12,7 @@ import { HelpCommandUseCase } from '@usecases/commands/help'; import { LoremCommandUseCase, LoremGenerator } from '@usecases/commands/lorem'; import { FakerLoremGenerator } from './faker.lorem.generator'; import { DispatcherModule } from '../dispatcher/dispatcher.module'; +import { ChangeRoomJoinPolicyUseCase } from '@usecases/rooms/change-room-join-policy'; @Module({ imports: [AuthModule, DispatcherModule], @@ -28,6 +29,7 @@ import { DispatcherModule } from '../dispatcher/dispatcher.module'; CommandService, RenameRoomUseCase, RenameUserUseCase, + ChangeRoomJoinPolicyUseCase, LoremCommandUseCase, HelpCommandUseCase, ], diff --git a/services/api/src/domain/entities/rooms.repository.ts b/services/api/src/domain/entities/rooms.repository.ts index a98d535b..07a5cba7 100644 --- a/services/api/src/domain/entities/rooms.repository.ts +++ b/services/api/src/domain/entities/rooms.repository.ts @@ -1,7 +1,9 @@ import { Room } from './room.entity'; export type CreateRoomParams = Omit; -export type UpdateRoomParams = Partial> & Pick; +export type UpdateRoomParams = Partial< + Pick +>; export abstract class RoomsRepository { abstract createRoom(params: CreateRoomParams): Promise; diff --git a/services/api/src/domain/usecases/commands/help.spec.ts b/services/api/src/domain/usecases/commands/help.spec.ts index 59459f53..d0b86796 100644 --- a/services/api/src/domain/usecases/commands/help.spec.ts +++ b/services/api/src/domain/usecases/commands/help.spec.ts @@ -32,6 +32,7 @@ describe('HelpCommandUseCase', () => { "* `/lorem {count} {'words' | 'paragraphs'}`: generate lorem text", '* `/rename room {name}`: change the room name', '* `/rename user {name}`: change your display name', + "* `/set room join policy {'anyone', 'invite'}`: set the room join policy", ].join('\n'), }); }); diff --git a/services/api/src/domain/usecases/commands/parse/command.parser.ts b/services/api/src/domain/usecases/commands/parse/command.parser.ts index 81255801..f173a496 100644 --- a/services/api/src/domain/usecases/commands/parse/command.parser.ts +++ b/services/api/src/domain/usecases/commands/parse/command.parser.ts @@ -1,4 +1,5 @@ import { Command } from '@entities/command.entity'; +import { JoinPolicy } from '@entities/room.entity'; import { BadRequestException } from '@nestjs/common'; import { equals } from 'rambda'; import { z, ZodIssue, ZodType } from 'zod'; @@ -7,6 +8,7 @@ export type ParsedCommand = | { tag: 'help'; params: null } | { tag: 'renameRoom'; params: { newName: string } } | { tag: 'renameUser'; params: { newName: string } } + | { tag: 'changeRoomJoinPolicy'; params: { newJoinPolicy: JoinPolicy } } | { tag: 'lorem'; params: { count: number; typeToken: 'words' | 'paragraphs' }; diff --git a/services/api/src/domain/usecases/commands/parse/parsers/change-room-join-policy-parser.ts b/services/api/src/domain/usecases/commands/parse/parsers/change-room-join-policy-parser.ts new file mode 100644 index 00000000..4b50d455 --- /dev/null +++ b/services/api/src/domain/usecases/commands/parse/parsers/change-room-join-policy-parser.ts @@ -0,0 +1,24 @@ +import { z } from 'zod'; +import { CommandParser, ParsedCommand } from '../command.parser'; +import { JoinPolicy } from '@entities/room.entity'; + +const schema = z + .tuple([ + z.literal('set'), + z.literal('room'), + z.literal('join'), + z.literal('policy'), + z.enum([JoinPolicy.Anyone, JoinPolicy.Invite]), + ]) + .rest(z.string()) + .transform(([, , , , joinPolicy]) => ({ + tag: 'changeRoomJoinPolicy', + params: { newJoinPolicy: joinPolicy }, + })); + +export const changeRoomJoinPolicyParser = new CommandParser({ + matchTokens: ['set', 'room', 'join', 'policy'], + schema, + signature: `/set room join policy {'anyone', 'invite'}`, + summary: 'set the room join policy', +}); diff --git a/services/api/src/domain/usecases/commands/parse/parsers/index.ts b/services/api/src/domain/usecases/commands/parse/parsers/index.ts index a3065910..316ff6d6 100644 --- a/services/api/src/domain/usecases/commands/parse/parsers/index.ts +++ b/services/api/src/domain/usecases/commands/parse/parsers/index.ts @@ -1,4 +1,5 @@ import { CommandParser } from '../command.parser'; +import { changeRoomJoinPolicyParser } from './change-room-join-policy-parser'; import { helpParser } from './help.parser'; import { loremParser } from './lorem.parser'; import { renameRoomParser } from './rename.room.parser'; @@ -9,4 +10,5 @@ export const parsers: CommandParser[] = [ loremParser, renameRoomParser, renameUserParser, + changeRoomJoinPolicyParser, ]; diff --git a/services/api/src/domain/usecases/rooms/change-room-join-policy.spec.ts b/services/api/src/domain/usecases/rooms/change-room-join-policy.spec.ts new file mode 100644 index 00000000..b6f83224 --- /dev/null +++ b/services/api/src/domain/usecases/rooms/change-room-join-policy.spec.ts @@ -0,0 +1,84 @@ +import { TestAuthService } from '@fixtures/auth/test-auth-service'; +import { TestRoomsRepository } from '@fixtures/data/test.rooms.repository'; +import { mock, MockProxy } from 'jest-mock-extended'; +import { RoomFactory } from '@fixtures/messages/room.factory'; +import { UserFactory } from '@fixtures/messages/user.factory'; +import { UnauthorizedException } from '@nestjs/common'; +import { Dispatcher } from '@entities/messages'; +import { AppLogger } from '@app/app.logger'; +import { Role } from '@usecases/auth.service'; +import { ChangeRoomJoinPolicyUseCase } from './change-room-join-policy'; +import { JoinPolicy } from '@entities/room.entity'; + +describe('ChangeRoomJoinPolicyUseCase', () => { + let changeRoomJoinPolicy: ChangeRoomJoinPolicyUseCase; + let rooms: TestRoomsRepository; + let auth: TestAuthService; + let dispatcher: MockProxy; + + const originalJoinPolicy = JoinPolicy.Invite; + const newJoinPolicy = JoinPolicy.Anyone; + + const owner = UserFactory.build(); + const otherUser = UserFactory.build(); + const room = RoomFactory.build({ joinPolicy: originalJoinPolicy }); + + beforeEach(() => { + rooms = new TestRoomsRepository(); + rooms.setData([room]); + + auth = new TestAuthService(mock()); + auth.stubPermission({ user: owner, subject: room, action: Role.Manage }); + + dispatcher = mock(); + + changeRoomJoinPolicy = new ChangeRoomJoinPolicyUseCase( + rooms, + auth, + dispatcher, + ); + }); + + it('renames the room', async () => { + await changeRoomJoinPolicy.exec({ + roomId: room.id, + authenticatedUser: owner, + newJoinPolicy, + }); + + const updatedRoom = await rooms.getRoom(room.id); + expect(updatedRoom.joinPolicy).toEqual(newJoinPolicy); + }); + + it('sends a notification', async () => { + await changeRoomJoinPolicy.exec({ + roomId: room.id, + authenticatedUser: owner, + newJoinPolicy, + }); + + expect(dispatcher.send).toHaveBeenCalledWith({ + content: 'Room join policy updated to anyone', + authorId: 'system', + roomId: room.id, + updatedEntities: ['room'], + }); + }); + + it('authorizes the user', async () => { + await expect( + changeRoomJoinPolicy.exec({ + roomId: room.id, + authenticatedUser: otherUser, + newJoinPolicy, + }), + ).rejects.toEqual( + new UnauthorizedException( + `You cannot change this room's join policy. Only the owner can do this.`, + ), + ); + + const updatedRoom = await rooms.getRoom(room.id); + expect(updatedRoom.joinPolicy).toEqual(originalJoinPolicy); + }); +}); diff --git a/services/api/src/domain/usecases/rooms/change-room-join-policy.ts b/services/api/src/domain/usecases/rooms/change-room-join-policy.ts new file mode 100644 index 00000000..9badab4c --- /dev/null +++ b/services/api/src/domain/usecases/rooms/change-room-join-policy.ts @@ -0,0 +1,50 @@ +import { Dispatcher, DraftMessage, UpdatedEntity } from '@entities/messages'; +import { JoinPolicy } from '@entities/room.entity'; +import { RoomsRepository } from '@entities/rooms.repository'; +import { User } from '@entities/users'; +import { Injectable } from '@nestjs/common'; +import { AuthService, Role } from '@usecases/auth.service'; + +export type ChangeRoomJoinPolicyParams = { + roomId: string; + authenticatedUser: User; + newJoinPolicy: JoinPolicy; +}; + +@Injectable() +export class ChangeRoomJoinPolicyUseCase { + constructor( + private readonly rooms: RoomsRepository, + private readonly authService: AuthService, + private readonly dispatcher: Dispatcher, + ) {} + + async exec({ + roomId, + authenticatedUser, + newJoinPolicy, + }: ChangeRoomJoinPolicyParams): Promise { + const room = await this.rooms.getRoom(roomId); + + await this.authService.authorize({ + user: authenticatedUser, + subject: room, + action: Role.Manage, + message: `You cannot change this room's join policy. Only the owner can do this.`, + }); + + await this.rooms.updateRoom({ + id: roomId, + joinPolicy: newJoinPolicy, + }); + + const message: DraftMessage = { + content: `Room join policy updated to ${newJoinPolicy}`, + roomId: room.id, + authorId: 'system', + updatedEntities: [UpdatedEntity.Room], + }; + + await this.dispatcher.send(message); + } +} diff --git a/services/api/src/domain/usecases/rooms/create.spec.ts b/services/api/src/domain/usecases/rooms/create.spec.ts index d9cb3d3f..61a388b5 100644 --- a/services/api/src/domain/usecases/rooms/create.spec.ts +++ b/services/api/src/domain/usecases/rooms/create.spec.ts @@ -2,6 +2,7 @@ import { TestMembershipsRepository } from '@fixtures/data/test.memberships.repos import { TestRoomsRepository } from '@fixtures/data/test.rooms.repository'; import { UserFactory } from '@fixtures/messages/user.factory'; import { CreateRoomUseCase } from './create'; +import { ContentPolicy, JoinPolicy } from '@entities/room.entity'; describe('CreateRoomUseCase', () => { let create: CreateRoomUseCase; @@ -23,8 +24,8 @@ describe('CreateRoomUseCase', () => { const room = await create.exec(owner); expect(room).toMatchObject({ ownerId: owner.id, - contentPolicy: 'private', - joinPolicy: 'anyone', + contentPolicy: ContentPolicy.Private, + joinPolicy: JoinPolicy.Invite, }); }); diff --git a/services/api/src/domain/usecases/rooms/create.ts b/services/api/src/domain/usecases/rooms/create.ts index 7eb438e0..65c69f7a 100644 --- a/services/api/src/domain/usecases/rooms/create.ts +++ b/services/api/src/domain/usecases/rooms/create.ts @@ -19,7 +19,7 @@ export class CreateRoomUseCase { ownerId: owner.id, name: titleCase(name), contentPolicy: ContentPolicy.Private, - joinPolicy: JoinPolicy.Anyone, + joinPolicy: JoinPolicy.Invite, }); await this.memberships.createMembership({ userId: owner.id,