From bed27ecd57d13c8d60e237c17359715ab9b5cbfa Mon Sep 17 00:00:00 2001 From: John Brunton Date: Sat, 24 Aug 2024 18:59:34 +0100 Subject: [PATCH] feature: request join policy --- client/src/data/rooms.ts | 6 + .../src/features/room/organisms/ChatBox.tsx | 31 +++- client/src/features/room/pages/Room.tsx | 3 +- .../api/src/app/auth/permissions/roles.ts | 2 +- .../api/src/app/messages/command.service.ts | 9 + .../api/src/app/messages/messages.module.ts | 2 + .../src/domain/entities/membership.entity.ts | 12 +- .../src/domain/usecases/commands/help.spec.ts | 3 +- .../usecases/commands/parse/command.parser.ts | 1 + .../usecases/commands/parse/parse.spec.ts | 27 ++- .../parse/parsers/approve-request-parser.ts | 16 ++ .../parsers/change-room-join-policy-parser.ts | 4 +- .../usecases/commands/parse/parsers/index.ts | 2 + .../usecases/rooms/approve-request.spec.ts | 154 ++++++++++++++++++ .../domain/usecases/rooms/approve-request.ts | 95 +++++++++++ .../api/src/domain/usecases/rooms/get.spec.ts | 23 ++- services/api/src/domain/usecases/rooms/get.ts | 13 +- .../src/domain/usecases/rooms/invite.spec.ts | 2 +- .../api/src/domain/usecases/rooms/invite.ts | 4 +- .../src/domain/usecases/rooms/join.spec.ts | 34 +++- .../api/src/domain/usecases/rooms/join.ts | 19 ++- 21 files changed, 438 insertions(+), 24 deletions(-) create mode 100644 services/api/src/domain/usecases/commands/parse/parsers/approve-request-parser.ts create mode 100644 services/api/src/domain/usecases/rooms/approve-request.spec.ts create mode 100644 services/api/src/domain/usecases/rooms/approve-request.ts diff --git a/client/src/data/rooms.ts b/client/src/data/rooms.ts index 494acd25..34db2f83 100644 --- a/client/src/data/rooms.ts +++ b/client/src/data/rooms.ts @@ -9,9 +9,15 @@ export type Room = { joinPolicy: string } +export type Membership = { + roomId: string + status: string +} + export type RoomResponse = { room: Room roles: string[] + membership?: Membership } const getRoom = async (roomId?: string): Promise => { diff --git a/client/src/features/room/organisms/ChatBox.tsx b/client/src/features/room/organisms/ChatBox.tsx index 7dfbab64..4341b49b 100644 --- a/client/src/features/room/organisms/ChatBox.tsx +++ b/client/src/features/room/organisms/ChatBox.tsx @@ -2,15 +2,21 @@ import { Button, Icon, Textarea, Spinner, VStack, Alert, AlertIcon, Spacer } fro import React, { useState, KeyboardEventHandler, useRef, useEffect, ReactElement } from 'react' import { AiOutlineArrowRight } from 'react-icons/ai' import { usePostMessage } from '../../../data/messages' -import { useJoinRoom } from '../../../data/rooms' +import { RoomResponse, useJoinRoom } from '../../../data/rooms' import { useUserDetails } from '../../../data/users' +import { can } from '../../../data/lib' export type ChatBoxProps = { - roomId: string - canJoin: boolean + roomResponse: RoomResponse } -const JoinAlert = ({ roomId, canJoin }: ChatBoxProps) => { +const JoinAlert = ({ roomResponse }: ChatBoxProps): ReactElement => { + const roomId = roomResponse.room.id + + const canJoin = can('join', roomResponse) + const requiresApproval = roomResponse.room.joinPolicy === 'request' + const awaitingApproval = roomResponse.membership?.status === 'PendingApproval' + const { mutate: joinRoom, isLoading, isSuccess: isJoined } = useJoinRoom(roomId) useEffect(() => { @@ -20,13 +26,22 @@ const JoinAlert = ({ roomId, canJoin }: ChatBoxProps) => { }, [isJoined]) if (canJoin) { + if (requiresApproval && awaitingApproval) { + return ( + + + Awaiting approval from owner. + + + ) + } return ( Join this room to chat. ) @@ -40,7 +55,9 @@ const JoinAlert = ({ roomId, canJoin }: ChatBoxProps) => { ) } -export const ChatBox: React.FC = ({ roomId, canJoin }: ChatBoxProps): ReactElement => { +export const ChatBox: React.FC = ({ roomResponse }: ChatBoxProps): ReactElement => { + const roomId = roomResponse.room.id + const [content, setContent] = useState('') const { data: user, isLoading } = useUserDetails() const joined = user?.rooms.some((room) => room.id === roomId) @@ -67,7 +84,7 @@ export const ChatBox: React.FC = ({ roomId, canJoin }: ChatBoxProp } 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 fa4a0f6c..c2015994 100644 --- a/client/src/features/room/pages/Room.tsx +++ b/client/src/features/room/pages/Room.tsx @@ -17,7 +17,6 @@ export const RoomPage = () => { 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 }) @@ -27,7 +26,7 @@ export const RoomPage = () => { return ( {} - + ) } diff --git a/services/api/src/app/auth/permissions/roles.ts b/services/api/src/app/auth/permissions/roles.ts index 9ff3de2d..4318c9e0 100644 --- a/services/api/src/app/auth/permissions/roles.ts +++ b/services/api/src/app/auth/permissions/roles.ts @@ -40,7 +40,7 @@ export const defineRolesForUser = (user: User, memberships: Membership[]) => { }); can(Role.Join, 'Room', { - joinPolicy: JoinPolicy.Anyone, + joinPolicy: { $in: [JoinPolicy.Anyone, JoinPolicy.Request] }, }); can(Role.Join, 'Room', { id: { $in: pendingInviteRoomIds }, diff --git a/services/api/src/app/messages/command.service.ts b/services/api/src/app/messages/command.service.ts index d6102d41..61fd7395 100644 --- a/services/api/src/app/messages/command.service.ts +++ b/services/api/src/app/messages/command.service.ts @@ -12,6 +12,7 @@ import { P, match } from 'ts-pattern'; import { InviteUseCase } from '@usecases/rooms/invite'; import { LeaveRoomUseCase } from '@usecases/rooms/leave'; import { AboutRoomUseCase } from '@usecases/rooms/about-room'; +import { ApproveRequestUseCase } from '@usecases/rooms/approve-request'; @Injectable() export class CommandService { @@ -22,6 +23,7 @@ export class CommandService { private readonly help: HelpCommandUseCase, private readonly leave: LeaveRoomUseCase, private readonly parse: ParseCommandUseCase, + private readonly approveRequest: ApproveRequestUseCase, private readonly changeRoomJoinPolicy: ChangeRoomJoinPolicyUseCase, private readonly aboutRoom: AboutRoomUseCase, private readonly invite: InviteUseCase, @@ -57,6 +59,13 @@ export class CommandService { authenticatedUser, }), ) + .with({ tag: 'approveRequest', params: P.select() }, (params) => + this.approveRequest.exec({ + ...params, + roomId, + authenticatedUser, + }), + ) .with({ tag: 'aboutRoom' }, () => this.aboutRoom.exec({ roomId, diff --git a/services/api/src/app/messages/messages.module.ts b/services/api/src/app/messages/messages.module.ts index 141887cf..00e37024 100644 --- a/services/api/src/app/messages/messages.module.ts +++ b/services/api/src/app/messages/messages.module.ts @@ -16,6 +16,7 @@ import { ChangeRoomJoinPolicyUseCase } from '@usecases/rooms/change-room-join-po import { InviteUseCase } from '@usecases/rooms/invite'; import { LeaveRoomUseCase } from '@usecases/rooms/leave'; import { AboutRoomUseCase } from '@usecases/rooms/about-room'; +import { ApproveRequestUseCase } from '@usecases/rooms/approve-request'; @Module({ imports: [AuthModule, DispatcherModule], @@ -37,6 +38,7 @@ import { AboutRoomUseCase } from '@usecases/rooms/about-room'; LeaveRoomUseCase, LoremCommandUseCase, AboutRoomUseCase, + ApproveRequestUseCase, HelpCommandUseCase, ], exports: [MessagesService], diff --git a/services/api/src/domain/entities/membership.entity.ts b/services/api/src/domain/entities/membership.entity.ts index 1ebc8858..9eb4a18b 100644 --- a/services/api/src/domain/entities/membership.entity.ts +++ b/services/api/src/domain/entities/membership.entity.ts @@ -82,8 +82,18 @@ export const isPendingInvite = allPass([ withStatus(MembershipStatus.PendingInvite), ]); +export const isPendingApproval = allPass([ + isCurrent, + withStatus(MembershipStatus.PendingApproval), +]); + export const isMemberOf = (roomId: string, memberships: Membership[]) => memberships.some(allPass([isActive, forRoom(roomId)])); -export const hasInviteTo = (roomId: string, memberships: Membership[]) => +export const hasPendingInviteTo = (roomId: string, memberships: Membership[]) => memberships.some(allPass([isPendingInvite, forRoom(roomId)])); + +export const hasPendingRequestTo = ( + roomId: string, + memberships: Membership[], +) => memberships.some(allPass([isPendingApproval, forRoom(roomId)])); diff --git a/services/api/src/domain/usecases/commands/help.spec.ts b/services/api/src/domain/usecases/commands/help.spec.ts index 9763f44b..08ff4b0b 100644 --- a/services/api/src/domain/usecases/commands/help.spec.ts +++ b/services/api/src/domain/usecases/commands/help.spec.ts @@ -33,9 +33,10 @@ describe('HelpCommandUseCase', () => { '* `/rename room {name}`: change the room name', '* `/rename user {name}`: change your display name', '* `/leave`: leave room', - "* `/set room join policy {'anyone', 'invite'}`: set the room join policy", + "* `/set room join policy {'anyone', 'request', 'invite'}`: set the room join policy", '* `/about room`: about room (including policies)', '* `/invite {email}`: invite a user to the room', + '* `/approve request {email}`: approve pending request to join the room', ].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 b6950173..5b5d6041 100644 --- a/services/api/src/domain/usecases/commands/parse/command.parser.ts +++ b/services/api/src/domain/usecases/commands/parse/command.parser.ts @@ -9,6 +9,7 @@ export type ParsedCommand = | { tag: 'renameRoom'; params: { newName: string } } | { tag: 'renameUser'; params: { newName: string } } | { tag: 'inviteUser'; params: { email: string } } + | { tag: 'approveRequest'; params: { email: string } } | { tag: 'leave'; params: null } | { tag: 'aboutRoom'; params: null } | { tag: 'changeRoomJoinPolicy'; params: { newJoinPolicy: JoinPolicy } } diff --git a/services/api/src/domain/usecases/commands/parse/parse.spec.ts b/services/api/src/domain/usecases/commands/parse/parse.spec.ts index 8a3f6adc..6fb26713 100644 --- a/services/api/src/domain/usecases/commands/parse/parse.spec.ts +++ b/services/api/src/domain/usecases/commands/parse/parse.spec.ts @@ -159,7 +159,7 @@ describe('ParseCommandUseCase', () => { it('validates the number of arguments', () => { withMessage('/set room join policy').expectError( 'Error in command `/set room join policy`:', - `* Received too few arguments. Expected: \`/set room join policy {'anyone', 'invite'}\``, + `* Received too few arguments. Expected: \`/set room join policy {'anyone', 'request', 'invite'}\``, ); }); }); @@ -189,6 +189,31 @@ describe('ParseCommandUseCase', () => { }); }); + describe('/approve request', () => { + it('parses valid commands', () => { + withMessage('/approve request joe.bloggs@example.com').expectCommand({ + tag: 'approveRequest', + params: { + email: 'joe.bloggs@example.com', + }, + }); + }); + + it('validates the number of arguments', () => { + withMessage('/approve request').expectError( + 'Error in command `/approve request`:', + `* Received too few arguments. Expected: \`/approve request {email}\``, + ); + }); + + it('validates the email', () => { + withMessage('/approve request not-an-email').expectError( + 'Error in command `/approve request not-an-email`:', + `* Argument 2 (\`not-an-email\`): Invalid email`, + ); + }); + }); + it('responds if the command is unrecognised', () => { const command: Command = { roomId: 'my-room', diff --git a/services/api/src/domain/usecases/commands/parse/parsers/approve-request-parser.ts b/services/api/src/domain/usecases/commands/parse/parsers/approve-request-parser.ts new file mode 100644 index 00000000..ef80730d --- /dev/null +++ b/services/api/src/domain/usecases/commands/parse/parsers/approve-request-parser.ts @@ -0,0 +1,16 @@ +import { z } from 'zod'; +import { CommandParser, ParsedCommand } from '../command.parser'; + +const schema = z + .tuple([z.literal('approve'), z.literal('request'), z.string().email()]) + .transform(([, , email]) => ({ + tag: 'approveRequest', + params: { email }, + })); + +export const approveRequestParser = new CommandParser({ + matchTokens: ['approve', 'request'], + schema, + signature: `/approve request {email}`, + summary: 'approve pending request to join the room', +}); 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 index 4b50d455..d32096a7 100644 --- 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 @@ -8,7 +8,7 @@ const schema = z z.literal('room'), z.literal('join'), z.literal('policy'), - z.enum([JoinPolicy.Anyone, JoinPolicy.Invite]), + z.enum([JoinPolicy.Anyone, JoinPolicy.Invite, JoinPolicy.Request]), ]) .rest(z.string()) .transform(([, , , , joinPolicy]) => ({ @@ -19,6 +19,6 @@ const schema = z export const changeRoomJoinPolicyParser = new CommandParser({ matchTokens: ['set', 'room', 'join', 'policy'], schema, - signature: `/set room join policy {'anyone', 'invite'}`, + signature: `/set room join policy {'anyone', 'request', '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 22c8cd1f..ecf7dc6f 100644 --- a/services/api/src/domain/usecases/commands/parse/parsers/index.ts +++ b/services/api/src/domain/usecases/commands/parse/parsers/index.ts @@ -1,5 +1,6 @@ import { CommandParser } from '../command.parser'; import { aboutRoomParser } from './about-room-parser'; +import { approveRequestParser } from './approve-request-parser'; import { changeRoomJoinPolicyParser } from './change-room-join-policy-parser'; import { helpParser } from './help.parser'; import { inviteParser } from './invite-parser'; @@ -17,4 +18,5 @@ export const parsers: CommandParser[] = [ changeRoomJoinPolicyParser, aboutRoomParser, inviteParser, + approveRequestParser, ]; diff --git a/services/api/src/domain/usecases/rooms/approve-request.spec.ts b/services/api/src/domain/usecases/rooms/approve-request.spec.ts new file mode 100644 index 00000000..f12fd373 --- /dev/null +++ b/services/api/src/domain/usecases/rooms/approve-request.spec.ts @@ -0,0 +1,154 @@ +import { JoinPolicy } from '@entities/room.entity'; +import { TestAuthService } from '@fixtures/auth/test-auth-service'; +import { TestMembershipsRepository } from '@fixtures/data/test.memberships.repository'; +import { TestRoomsRepository } from '@fixtures/data/test.rooms.repository'; +import { RoomFactory } from '@fixtures/messages/room.factory'; +import { UserFactory } from '@fixtures/messages/user.factory'; +import { UnauthorizedException } from '@nestjs/common'; +import { AppLogger } from '@app/app.logger'; +import { Role } from '@usecases/auth.service'; +import mock, { MockProxy } from 'jest-mock-extended/lib/Mock'; +import { Dispatcher } from '@entities/messages/message'; +import { TestUsersRepository } from '@fixtures/data/test.users.repository'; +import { MembershipStatus, isCurrent } from '@entities/membership.entity'; +import { ApproveRequestUseCase } from './approve-request'; + +describe('ApproveRequestUseCase', () => { + let approveRequest: ApproveRequestUseCase; + let rooms: TestRoomsRepository; + let users: TestUsersRepository; + let memberships: TestMembershipsRepository; + let auth: TestAuthService; + let dispatcher: MockProxy; + + const owner = UserFactory.build({ name: 'Alice' }); + const otherUser = UserFactory.build({ name: 'Bob' }); + const room = RoomFactory.build({ joinPolicy: JoinPolicy.Invite }); + + const now = new Date(1000); + + beforeEach(() => { + rooms = new TestRoomsRepository(); + rooms.setData([room]); + + users = new TestUsersRepository(); + users.setData([owner, otherUser]); + + memberships = new TestMembershipsRepository(); + + auth = new TestAuthService(mock()); + auth.stubPermission({ user: owner, subject: room, action: Role.Manage }); + + dispatcher = mock(); + + approveRequest = new ApproveRequestUseCase( + rooms, + users, + memberships, + auth, + dispatcher, + ); + + jest.useFakeTimers({ now }); + }); + + it('approves pending requests', async () => { + memberships.setData([ + { + userId: otherUser.id, + roomId: room.id, + status: MembershipStatus.PendingApproval, + from: 0, + }, + ]); + + await approveRequest.exec({ + authenticatedUser: owner, + roomId: room.id, + email: otherUser.email, + }); + + expect(memberships.getData().filter(isCurrent)).toEqual([ + { + userId: otherUser.id, + roomId: room.id, + status: MembershipStatus.Joined, + from: now.getTime(), + }, + ]); + + expect(dispatcher.send).toHaveBeenCalledWith({ + content: 'Alice approved Bob to join the room', + authorId: 'system', + roomId: room.id, + }); + }); + + it('authorizes the user', async () => { + await expect( + approveRequest.exec({ + authenticatedUser: otherUser, + roomId: room.id, + email: otherUser.email, + }), + ).rejects.toEqual( + new UnauthorizedException( + 'You do not have permission to perform this action.', + ), + ); + }); + + it('checks the user exists', async () => { + await approveRequest.exec({ + authenticatedUser: owner, + roomId: room.id, + email: 'not-a-user@example.com', + }); + + expect(dispatcher.send).toHaveBeenCalledWith({ + content: 'No user exists with email not-a-user@example.com', + authorId: 'system', + roomId: room.id, + recipientId: owner.id, + }); + }); + + it('checks the user is not already a member', async () => { + await memberships.setData([ + { + from: 0, + roomId: room.id, + userId: otherUser.id, + status: MembershipStatus.Joined, + }, + ]); + + await approveRequest.exec({ + authenticatedUser: owner, + roomId: room.id, + email: otherUser.email, + }); + + expect(dispatcher.send).toHaveBeenCalledWith({ + content: 'Bob is already a member of this room', + authorId: 'system', + roomId: room.id, + recipientId: owner.id, + }); + }); + + it('checks the user has a pending invite', async () => { + await approveRequest.exec({ + authenticatedUser: owner, + roomId: room.id, + email: otherUser.email, + }); + + expect(dispatcher.send).toHaveBeenCalledWith({ + content: 'Bob does not have a pending request to join this room', + authorId: 'system', + roomId: room.id, + recipientId: owner.id, + }); + }); +}); diff --git a/services/api/src/domain/usecases/rooms/approve-request.ts b/services/api/src/domain/usecases/rooms/approve-request.ts new file mode 100644 index 00000000..ed54d6ba --- /dev/null +++ b/services/api/src/domain/usecases/rooms/approve-request.ts @@ -0,0 +1,95 @@ +import { + MembershipStatus, + hasPendingRequestTo, + isMemberOf, +} from '@entities/membership.entity'; +import { MembershipsRepository } from '@entities/memberships.repository'; +import { Dispatcher, DraftMessage } from '@entities/messages'; +import { RoomsRepository } from '@entities/rooms.repository'; +import { User, UsersRepository } from '@entities/users'; +import { Injectable } from '@nestjs/common'; +import { AuthService, Role } from '@usecases/auth.service'; + +export type ApproveRequestParams = { + authenticatedUser: User; + roomId: string; + email: string; +}; + +@Injectable() +export class ApproveRequestUseCase { + constructor( + private readonly rooms: RoomsRepository, + private readonly users: UsersRepository, + private readonly memberships: MembershipsRepository, + private readonly auth: AuthService, + private readonly dispatcher: Dispatcher, + ) {} + + async exec({ + authenticatedUser, + roomId, + email, + }: ApproveRequestParams): Promise { + const room = await this.rooms.getRoom(roomId); + + await this.auth.authorize({ + user: authenticatedUser, + subject: room, + action: Role.Manage, + }); + + const invitedUser = await this.users.findUser(email); + + if (!invitedUser) { + const message: DraftMessage = { + content: `No user exists with email ${email}`, + roomId: room.id, + authorId: 'system', + recipientId: authenticatedUser.id, + }; + + await this.dispatcher.send(message); + return; + } + + const existingMemberships = await this.memberships.getMemberships( + invitedUser.id, + ); + if (isMemberOf(roomId, existingMemberships)) { + const message: DraftMessage = { + content: `${invitedUser.name} is already a member of this room`, + roomId: room.id, + authorId: 'system', + recipientId: authenticatedUser.id, + }; + + await this.dispatcher.send(message); + return; + } else if (!hasPendingRequestTo(roomId, existingMemberships)) { + const message: DraftMessage = { + content: `${invitedUser.name} does not have a pending request to join this room`, + roomId: room.id, + authorId: 'system', + recipientId: authenticatedUser.id, + }; + + await this.dispatcher.send(message); + return; + } + + await this.memberships.createMembership({ + userId: invitedUser.id, + roomId, + status: MembershipStatus.Joined, + }); + + const message: DraftMessage = { + content: `${authenticatedUser.name} approved ${invitedUser.name} to join the room`, + roomId: room.id, + authorId: 'system', + }; + + await this.dispatcher.send(message); + } +} diff --git a/services/api/src/domain/usecases/rooms/get.spec.ts b/services/api/src/domain/usecases/rooms/get.spec.ts index fe3dccd2..85cb6b58 100644 --- a/services/api/src/domain/usecases/rooms/get.spec.ts +++ b/services/api/src/domain/usecases/rooms/get.spec.ts @@ -5,10 +5,13 @@ import { UserFactory } from '@fixtures/messages/user.factory'; import { GetRoomUseCase } from './get'; import { AppLogger } from '@app/app.logger'; import { Role } from '@usecases/auth.service'; +import { TestMembershipsRepository } from '@fixtures/data/test.memberships.repository'; +import { MembershipStatus } from '@entities/membership.entity'; describe('GetRoomUseCase', () => { let get: GetRoomUseCase; let rooms: TestRoomsRepository; + let memberships: TestMembershipsRepository; let auth: TestAuthService; const user = UserFactory.build(); @@ -17,8 +20,12 @@ describe('GetRoomUseCase', () => { beforeEach(() => { rooms = new TestRoomsRepository(); rooms.setData([room]); + + memberships = new TestMembershipsRepository(); + auth = new TestAuthService(new AppLogger()); - get = new GetRoomUseCase(rooms, auth); + + get = new GetRoomUseCase(rooms, memberships, auth); }); it('returns the room', async () => { @@ -31,4 +38,18 @@ describe('GetRoomUseCase', () => { const details = await get.exec(room.id, user); expect(details.roles).toEqual([Role.Read]); }); + + it('returns the current membership for the authenticated user', async () => { + const currentMembership = { + roomId: room.id, + userId: user.id, + status: MembershipStatus.Joined, + from: 1000, + }; + memberships.setData([currentMembership]); + + const details = await get.exec(room.id, user); + + expect(details.membership).toEqual(currentMembership); + }); }); diff --git a/services/api/src/domain/usecases/rooms/get.ts b/services/api/src/domain/usecases/rooms/get.ts index 08f8ea43..14ee01d8 100644 --- a/services/api/src/domain/usecases/rooms/get.ts +++ b/services/api/src/domain/usecases/rooms/get.ts @@ -3,25 +3,36 @@ import { Room } from '@entities/room.entity'; import { RoomsRepository } from '@entities/rooms.repository'; import { User } from '@entities/users'; import { Injectable } from '@nestjs/common'; +import { MembershipsRepository } from '@entities/memberships.repository'; +import { Membership, forRoom, isCurrent } from '@entities/membership.entity'; export type RoomDetails = { room: Room; roles: Role[]; + membership?: Membership; }; @Injectable() export class GetRoomUseCase { constructor( private readonly rooms: RoomsRepository, + private readonly memberships: MembershipsRepository, private readonly authService: AuthService, ) {} async exec(roomId: string, user: User): Promise { const room = await this.rooms.getRoom(roomId); + const roles = await this.authService.authorizedRoles({ user, subject: room, }); - return { room, roles }; + + const memberships = await this.memberships.getMemberships(user.id); + const [currentMembership] = memberships + .filter(forRoom(roomId)) + .filter(isCurrent); + + return { room, roles, membership: currentMembership }; } } diff --git a/services/api/src/domain/usecases/rooms/invite.spec.ts b/services/api/src/domain/usecases/rooms/invite.spec.ts index 799f1842..787723d6 100644 --- a/services/api/src/domain/usecases/rooms/invite.spec.ts +++ b/services/api/src/domain/usecases/rooms/invite.spec.ts @@ -122,7 +122,7 @@ describe('InviteUseCase', () => { }); }); - it('checks the user doesn not already have an invite', async () => { + it('checks the user does not already have an invite', async () => { await memberships.setData([ { from: 0, diff --git a/services/api/src/domain/usecases/rooms/invite.ts b/services/api/src/domain/usecases/rooms/invite.ts index 24f2c1ba..a4ff0903 100644 --- a/services/api/src/domain/usecases/rooms/invite.ts +++ b/services/api/src/domain/usecases/rooms/invite.ts @@ -1,7 +1,7 @@ import { AuthService, Role } from '@usecases/auth.service'; import { MembershipStatus, - hasInviteTo, + hasPendingInviteTo, isMemberOf, } from '@entities/membership.entity'; import { MembershipsRepository } from '@entities/memberships.repository'; @@ -66,7 +66,7 @@ export class InviteUseCase { await this.dispatcher.send(message); return; - } else if (hasInviteTo(roomId, existingMemberships)) { + } else if (hasPendingInviteTo(roomId, existingMemberships)) { const message: DraftMessage = { content: `${invitedUser.name} already has an invite to this room`, roomId: room.id, diff --git a/services/api/src/domain/usecases/rooms/join.spec.ts b/services/api/src/domain/usecases/rooms/join.spec.ts index ebb8ab64..0416dc61 100644 --- a/services/api/src/domain/usecases/rooms/join.spec.ts +++ b/services/api/src/domain/usecases/rooms/join.spec.ts @@ -18,7 +18,10 @@ describe('JoinRoomUseCase', () => { let auth: TestAuthService; let dispatcher: MockProxy; - const user = UserFactory.build({ name: 'Joe Bloggs' }); + const user = UserFactory.build({ + name: 'Joe Bloggs', + email: 'joe.bloggs@example.com', + }); const now = new Date(1000); @@ -31,7 +34,7 @@ describe('JoinRoomUseCase', () => { jest.useFakeTimers({ now }); }); - it('assigns a membership to the user', async () => { + it('assigns a membership with status = "Joined" when the join policy is "anyone"', async () => { const room = RoomFactory.build({ joinPolicy: JoinPolicy.Anyone, }); @@ -56,6 +59,33 @@ describe('JoinRoomUseCase', () => { }); }); + it('assigns a membership with status = "PendingApproval" when the join policy is "request"', async () => { + const room = RoomFactory.build({ + joinPolicy: JoinPolicy.Request, + }); + rooms.setData([room]); + auth.stubPermission({ user, subject: room, action: Role.Join }); + + await join.exec(room.id, user); + + expect(memberships.getData()).toEqual([ + { + userId: user.id, + roomId: room.id, + status: 'PendingApproval', + from: now.getTime(), + }, + ]); + + expect(dispatcher.send).toHaveBeenCalledWith({ + content: + 'Joe Bloggs (joe.bloggs@example.com) requested approval to join the room', + authorId: 'system', + roomId: room.id, + recipientId: room.ownerId, + }); + }); + it('authorizes the user', async () => { const room = RoomFactory.build({ joinPolicy: JoinPolicy.Invite, diff --git a/services/api/src/domain/usecases/rooms/join.ts b/services/api/src/domain/usecases/rooms/join.ts index 8046e5d3..2046a07e 100644 --- a/services/api/src/domain/usecases/rooms/join.ts +++ b/services/api/src/domain/usecases/rooms/join.ts @@ -5,6 +5,7 @@ import { RoomsRepository } from '@entities/rooms.repository'; import { User } from '@entities/users'; import { Injectable } from '@nestjs/common'; import { Dispatcher, DraftMessage } from '@entities/messages/message'; +import { JoinPolicy } from '@entities/room.entity'; @Injectable() export class JoinRoomUseCase { @@ -24,16 +25,30 @@ export class JoinRoomUseCase { subject: room, }); + const status = + room.joinPolicy === JoinPolicy.Request + ? MembershipStatus.PendingApproval + : MembershipStatus.Joined; + await this.memberships.createMembership({ userId: user.id, roomId, - status: MembershipStatus.Joined, + status, }); + const messageContent = + room.joinPolicy === JoinPolicy.Request + ? `${user.name} (${user.email}) requested approval to join the room` + : `${user.name} joined the room. Welcome!`; + + const recipientId = + room.joinPolicy === JoinPolicy.Request ? room.ownerId : undefined; + const message: DraftMessage = { - content: `${user.name} joined the room. Welcome!`, + content: messageContent, roomId: room.id, authorId: 'system', + recipientId, }; await this.dispatcher.send(message);