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..2c398024 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.
+
+ : undefined} onClick={() => joinRoom()}>
+ Join
+
+
+ )
+ }
+
return (
- You need to join this room to chat.
-
- : undefined} onClick={() => joinRoom()}>
- Join
-
+ 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)
@@ -55,7 +67,7 @@ export const ChatBox: React.FC = ({ roomId }: ChatBoxProps) => {
}
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,