From 1e29b5d620b185b65bfaeef690912c747a2f6f1f Mon Sep 17 00:00:00 2001 From: John Brunton Date: Thu, 22 Aug 2024 19:32:30 +0100 Subject: [PATCH 01/12] feat: invite users --- services/api/package-lock.json | 12 ++++ services/api/package.json | 1 + .../src/app/auth/permissions/roles.spec.ts | 55 +++++++++++++++---- .../api/src/app/auth/permissions/roles.ts | 14 ++++- .../api/src/app/messages/command.service.ts | 43 +++++++++------ services/api/src/app/rooms/rooms.module.ts | 8 ++- services/api/src/data/adapters/schema.ts | 1 + .../dynamodb.users.repository.e2e-spec.ts | 4 ++ .../repositories/dynamodb.users.repository.ts | 19 ++++++- .../src/domain/entities/membership.entity.ts | 6 ++ .../domain/entities/users/users.repository.ts | 19 +++++-- .../usecases/commands/parse/command.parser.ts | 1 + .../commands/parse/parsers/invite-parser.ts | 16 ++++++ .../api/src/domain/usecases/rooms/invite.ts | 54 ++++++++++++++++++ 14 files changed, 213 insertions(+), 40 deletions(-) create mode 100644 services/api/src/domain/usecases/commands/parse/parsers/invite-parser.ts create mode 100644 services/api/src/domain/usecases/rooms/invite.ts diff --git a/services/api/package-lock.json b/services/api/package-lock.json index 1ca0a1d4..4d4695f4 100644 --- a/services/api/package-lock.json +++ b/services/api/package-lock.json @@ -37,6 +37,7 @@ "reflect-metadata": "^0.1.13", "rimraf": "^3.0.2", "rxjs": "^7.2.0", + "ts-pattern": "5.3.1", "zod": "3.21.4" }, "devDependencies": { @@ -14015,6 +14016,12 @@ } } }, + "node_modules/ts-pattern": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/ts-pattern/-/ts-pattern-5.3.1.tgz", + "integrity": "sha512-1RUMKa8jYQdNfmnK4jyzBK3/PS/tnjcZ1CW0v1vWDeYe5RBklc/nquw03MEoB66hVBm4BnlCfmOqDVxHyT1DpA==", + "license": "MIT" + }, "node_modules/tsconfig-paths": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-4.1.0.tgz", @@ -25444,6 +25451,11 @@ "yn": "3.1.1" } }, + "ts-pattern": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/ts-pattern/-/ts-pattern-5.3.1.tgz", + "integrity": "sha512-1RUMKa8jYQdNfmnK4jyzBK3/PS/tnjcZ1CW0v1vWDeYe5RBklc/nquw03MEoB66hVBm4BnlCfmOqDVxHyT1DpA==" + }, "tsconfig-paths": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-4.1.0.tgz", diff --git a/services/api/package.json b/services/api/package.json index 20949b5b..34091766 100644 --- a/services/api/package.json +++ b/services/api/package.json @@ -58,6 +58,7 @@ "reflect-metadata": "^0.1.13", "rimraf": "^3.0.2", "rxjs": "^7.2.0", + "ts-pattern": "5.3.1", "zod": "3.21.4" }, "devDependencies": { diff --git a/services/api/src/app/auth/permissions/roles.spec.ts b/services/api/src/app/auth/permissions/roles.spec.ts index 33eb4a3a..d52db0e9 100644 --- a/services/api/src/app/auth/permissions/roles.spec.ts +++ b/services/api/src/app/auth/permissions/roles.spec.ts @@ -1,8 +1,9 @@ import { Membership, MembershipStatus } from '@entities/membership.entity'; -import { ContentPolicy } from '@entities/room.entity'; +import { ContentPolicy, JoinPolicy } from '@entities/room.entity'; import { RoomFactory } from '@fixtures/messages/room.factory'; import { UserFactory } from '@fixtures/messages/user.factory'; import { defineRolesForUser } from './roles'; +import { Role } from '@usecases/auth.service'; describe('defineRolesForUser', () => { const user = UserFactory.build(); @@ -14,8 +15,8 @@ describe('defineRolesForUser', () => { const userAbility = defineRolesForUser(user, []); const otherUserAbility = defineRolesForUser(otherUser, []); - expect(userAbility.can('manage', room)).toEqual(true); - expect(otherUserAbility.can('manage', room)).toEqual(false); + expect(userAbility.can(Role.Manage, room)).toEqual(true); + expect(otherUserAbility.can(Role.Manage, room)).toEqual(false); }); it('grants read and write permissions for joined rooms', () => { @@ -34,13 +35,13 @@ describe('defineRolesForUser', () => { const userAbility = defineRolesForUser(user, memberships); const otherUserAbility = defineRolesForUser(otherUser, []); - expect(userAbility.can('read', room)).toEqual(true); - expect(userAbility.can('write', room)).toEqual(true); - expect(userAbility.can('manage', room)).toEqual(false); + expect(userAbility.can(Role.Read, room)).toEqual(true); + expect(userAbility.can(Role.Write, room)).toEqual(true); + expect(userAbility.can(Role.Manage, room)).toEqual(false); - expect(otherUserAbility.can('read', room)).toEqual(false); - expect(otherUserAbility.can('write', room)).toEqual(false); - expect(otherUserAbility.can('manage', room)).toEqual(false); + expect(otherUserAbility.can(Role.Read, room)).toEqual(false); + expect(otherUserAbility.can(Role.Write, room)).toEqual(false); + expect(otherUserAbility.can(Role.Manage, room)).toEqual(false); }); it('grants read permissions for public rooms', () => { @@ -50,8 +51,38 @@ describe('defineRolesForUser', () => { const userAbility = defineRolesForUser(user, []); - expect(userAbility.can('read', room)).toEqual(true); - expect(userAbility.can('write', room)).toEqual(false); - expect(userAbility.can('manage', room)).toEqual(false); + expect(userAbility.can(Role.Read, room)).toEqual(true); + expect(userAbility.can(Role.Write, room)).toEqual(false); + expect(userAbility.can(Role.Manage, room)).toEqual(false); + }); + + it('grants join permissions for rooms with a public join policy', () => { + const room = RoomFactory.build({ + joinPolicy: JoinPolicy.Anyone, + }); + + const userAbility = defineRolesForUser(user, []); + + expect(userAbility.can(Role.Join, room)).toEqual(true); + }); + + it('grants join permissions for invited users', () => { + const room = RoomFactory.build({ + joinPolicy: JoinPolicy.Invite, + }); + const memberships: Membership[] = [ + { + userId: user.id, + roomId: room.id, + status: MembershipStatus.PendingInvite, + from: 1000, + }, + ]; + + const userAbility = defineRolesForUser(user, memberships); + const otherUserAbility = defineRolesForUser(otherUser, []); + + expect(userAbility.can(Role.Join, room)).toEqual(true); + expect(otherUserAbility.can(Role.Join, room)).toEqual(false); }); }); diff --git a/services/api/src/app/auth/permissions/roles.ts b/services/api/src/app/auth/permissions/roles.ts index 42b6b9bc..068059fa 100644 --- a/services/api/src/app/auth/permissions/roles.ts +++ b/services/api/src/app/auth/permissions/roles.ts @@ -12,26 +12,34 @@ export const defineRolesForUser = (user: User, memberships: Membership[]) => { (membership) => !membership.until && membership.status === MembershipStatus.Joined, ); - const roomIds = pluck('roomId', activeMemberships); + const joinedRoomIds = pluck('roomId', activeMemberships); + + const pendingInvitations = memberships.filter( + (membership) => !membership.until && MembershipStatus.PendingInvite, + ); + const pendingInviteRoomIds = pluck('roomId', pendingInvitations); can(Role.Manage, 'Room', { ownerId: user.id, }); can(Role.Write, 'Room', { - id: { $in: roomIds }, + id: { $in: joinedRoomIds }, }); can(Role.Read, 'Room', { contentPolicy: ContentPolicy.Public, }); can(Role.Read, 'Room', { - id: { $in: roomIds }, + id: { $in: joinedRoomIds }, }); can(Role.Join, 'Room', { joinPolicy: JoinPolicy.Anyone, }); + can(Role.Join, 'Room', { + id: { $in: pendingInviteRoomIds }, + }); return build(); }; diff --git a/services/api/src/app/messages/command.service.ts b/services/api/src/app/messages/command.service.ts index 6f4f7028..aeb56111 100644 --- a/services/api/src/app/messages/command.service.ts +++ b/services/api/src/app/messages/command.service.ts @@ -8,6 +8,8 @@ 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'; +import { P, match } from 'ts-pattern'; +import { InviteUseCase } from '@usecases/rooms/invite'; @Injectable() export class CommandService { @@ -18,31 +20,36 @@ export class CommandService { private readonly help: HelpCommandUseCase, private readonly parse: ParseCommandUseCase, private readonly changeRoomJoinPolicy: ChangeRoomJoinPolicyUseCase, + private readonly invite: InviteUseCase, readonly dispatcher: Dispatcher, ) {} async exec(command: Command, authenticatedUser: User): Promise { const { roomId } = command; - const { tag, params } = await this.parse.exec(command); - switch (tag) { - case 'help': - await this.help.exec({ roomId, authenticatedUser }); - break; - case 'renameRoom': - await this.renameRoom.exec({ ...params, roomId, authenticatedUser }); - break; - case 'renameUser': - await this.renameUser.exec({ ...params, roomId, authenticatedUser }); - break; - case 'lorem': - await this.lorem.exec({ ...params, roomId, authenticatedUser }); - break; - case 'changeRoomJoinPolicy': - await this.changeRoomJoinPolicy.exec({ + const parsedCommand = await this.parse.exec(command); + return match(parsedCommand) + .with({ tag: 'help' }, () => + this.help.exec({ roomId, authenticatedUser }), + ) + .with({ tag: 'renameRoom', params: P.select() }, (params) => + this.renameRoom.exec({ ...params, roomId, authenticatedUser }), + ) + .with({ tag: 'renameUser', params: P.select() }, (params) => + this.renameUser.exec({ ...params, roomId, authenticatedUser }), + ) + .with({ tag: 'lorem', params: P.select() }, (params) => + this.lorem.exec({ ...params, roomId, authenticatedUser }), + ) + .with({ tag: 'changeRoomJoinPolicy', params: P.select() }, (params) => + this.changeRoomJoinPolicy.exec({ ...params, roomId, authenticatedUser, - }); - } + }), + ) + .with({ tag: 'inviteUser', params: P.select() }, (params) => { + this.invite.exec({ ...params, roomId, authenticatedUser }); + }) + .exhaustive(); } } diff --git a/services/api/src/app/rooms/rooms.module.ts b/services/api/src/app/rooms/rooms.module.ts index 3df1746f..f719eda4 100644 --- a/services/api/src/app/rooms/rooms.module.ts +++ b/services/api/src/app/rooms/rooms.module.ts @@ -6,11 +6,17 @@ import { JoinRoomUseCase } from '@usecases/rooms/join'; import { RoomsController } from './rooms.controller'; import { MessagesModule } from '@app/messages/messages.module'; import { DispatcherModule } from '@app/dispatcher/dispatcher.module'; +import { InviteUseCase } from '@usecases/rooms/invite'; @Module({ imports: [AuthModule, DispatcherModule, MessagesModule], controllers: [RoomsController], - providers: [CreateRoomUseCase, GetRoomUseCase, JoinRoomUseCase], + providers: [ + CreateRoomUseCase, + GetRoomUseCase, + JoinRoomUseCase, + InviteUseCase, + ], exports: [], }) export class RoomsModule {} diff --git a/services/api/src/data/adapters/schema.ts b/services/api/src/data/adapters/schema.ts index 3fd122a8..a93868b3 100644 --- a/services/api/src/data/adapters/schema.ts +++ b/services/api/src/data/adapters/schema.ts @@ -14,6 +14,7 @@ export const DbSchema = { Sort: { type: String, value: 'user', required: true }, sub: { type: String, required: true }, name: { type: String, required: true }, + email: { type: String, required: true, unique: true }, picture: { type: String }, }, Message: { diff --git a/services/api/src/data/repositories/dynamodb.users.repository.e2e-spec.ts b/services/api/src/data/repositories/dynamodb.users.repository.e2e-spec.ts index 8b88d8ab..0c61d2fe 100644 --- a/services/api/src/data/repositories/dynamodb.users.repository.e2e-spec.ts +++ b/services/api/src/data/repositories/dynamodb.users.repository.e2e-spec.ts @@ -37,6 +37,7 @@ describe('RoomsRepository', () => { const params: SaveUserParams = { sub: 'google_123', + email: 'some.user@example.com', name: 'Some User', }; @@ -45,10 +46,12 @@ describe('RoomsRepository', () => { expect(user).toMatchObject({ id: 'user:google_123', + email: 'some.user@example.com', name: 'Some User', }); expect(found).toMatchObject({ id: 'user:google_123', + email: 'some.user@example.com', name: 'Some User', }); }); @@ -57,6 +60,7 @@ describe('RoomsRepository', () => { const repo = repos[name]; const params: SaveUserParams = { name: 'Some User', + email: 'some.user@example.com', sub: 'user:google_123', }; const user = await repo.saveUser(params); diff --git a/services/api/src/data/repositories/dynamodb.users.repository.ts b/services/api/src/data/repositories/dynamodb.users.repository.ts index 7217c9ea..1daa006e 100644 --- a/services/api/src/data/repositories/dynamodb.users.repository.ts +++ b/services/api/src/data/repositories/dynamodb.users.repository.ts @@ -34,6 +34,23 @@ export class DynamoDBUsersRepository extends UsersRepository { return userFromRecord(user); } + override async findUser(email: string): Promise { + const [user] = await this.adapter.User.scan( + {}, + { + where: '${email} = @{email}', + substitutions: { + email, + }, + hidden: true, + }, + ); + if (!user) { + throw new NotFoundException(`User with email ${email} not found`); + } + return userFromRecord(user); + } + override async updateUser(params: UpdateUserParams): Promise { const { id, ...rest } = params; const user = await this.adapter.User.get( @@ -57,5 +74,5 @@ export class DynamoDBUsersRepository extends UsersRepository { const userFromRecord = (record: DbUser): User => ({ id: record.Id, - ...pick(['name', 'picture'], record), + ...pick(['name', 'picture', 'email'], record), }); diff --git a/services/api/src/domain/entities/membership.entity.ts b/services/api/src/domain/entities/membership.entity.ts index 8a56c7e5..a17524b1 100644 --- a/services/api/src/domain/entities/membership.entity.ts +++ b/services/api/src/domain/entities/membership.entity.ts @@ -12,6 +12,12 @@ export enum MembershipStatus { */ Joined = 'Joined', + /** + * The user has been invited by an admin but has not accepted. + */ + + PendingInvite = 'PendingInvite', + /** * The user has requested to join a room but is pending approval. */ diff --git a/services/api/src/domain/entities/users/users.repository.ts b/services/api/src/domain/entities/users/users.repository.ts index ad3533bb..3f19ecdf 100644 --- a/services/api/src/domain/entities/users/users.repository.ts +++ b/services/api/src/domain/entities/users/users.repository.ts @@ -4,6 +4,7 @@ import { AuthInfo } from '@entities/auth'; export type SaveUserParams = AuthInfo & { name: string; + email: string; }; export type UpdateUserParams = Partial> & Pick; @@ -11,11 +12,19 @@ export type UpdateUserParams = Partial> & Pick; export abstract class UsersRepository { abstract saveUser(params: SaveUserParams): Promise; abstract getUser(id: string): Promise; + abstract findUser(email: string): Promise; abstract updateUser(params: UpdateUserParams): Promise; } -export const userParamsFromAuth = (authInfo: AuthInfo): SaveUserParams => ({ - sub: authInfo.sub, - name: authInfo.name ?? `${faker.word.adverb()}-${faker.animal.bird()}`, - picture: authInfo.picture, -}); +export const userParamsFromAuth = (authInfo: AuthInfo): SaveUserParams => { + if (!authInfo.email) { + throw new Error('Missing email'); + } + + return { + sub: authInfo.sub, + email: authInfo.email, + name: authInfo.name ?? `${faker.word.adverb()}-${faker.animal.bird()}`, + picture: authInfo.picture, + }; +}; 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 f173a496..78562a9f 100644 --- a/services/api/src/domain/usecases/commands/parse/command.parser.ts +++ b/services/api/src/domain/usecases/commands/parse/command.parser.ts @@ -8,6 +8,7 @@ export type ParsedCommand = | { tag: 'help'; params: null } | { tag: 'renameRoom'; params: { newName: string } } | { tag: 'renameUser'; params: { newName: string } } + | { tag: 'inviteUser'; params: { email: string } } | { tag: 'changeRoomJoinPolicy'; params: { newJoinPolicy: JoinPolicy } } | { tag: 'lorem'; diff --git a/services/api/src/domain/usecases/commands/parse/parsers/invite-parser.ts b/services/api/src/domain/usecases/commands/parse/parsers/invite-parser.ts new file mode 100644 index 00000000..a9ca42dd --- /dev/null +++ b/services/api/src/domain/usecases/commands/parse/parsers/invite-parser.ts @@ -0,0 +1,16 @@ +import { z } from 'zod'; +import { CommandParser, ParsedCommand } from '../command.parser'; + +const schema = z + .tuple([z.literal('invite'), z.literal('user'), z.string()]) + .transform(([, , email]) => ({ + tag: 'inviteUser', + params: { email }, + })); + +export const changeRoomJoinPolicyParser = new CommandParser({ + matchTokens: ['invite', 'user'], + schema, + signature: `/invite user {email}`, + summary: 'invite a user to the room', +}); diff --git a/services/api/src/domain/usecases/rooms/invite.ts b/services/api/src/domain/usecases/rooms/invite.ts new file mode 100644 index 00000000..ec7dd9ba --- /dev/null +++ b/services/api/src/domain/usecases/rooms/invite.ts @@ -0,0 +1,54 @@ +import { AuthService, Role } from '@usecases/auth.service'; +import { MembershipStatus } from '@entities/membership.entity'; +import { MembershipsRepository } from '@entities/memberships.repository'; +import { RoomsRepository } from '@entities/rooms.repository'; +import { User, UsersRepository } from '@entities/users'; +import { Injectable } from '@nestjs/common'; +import { Dispatcher, DraftMessage } from '@entities/messages/message'; + +export type InviteParams = { + roomId: string; + authenticatedUser: User; + email: string; +}; + +@Injectable() +export class InviteUseCase { + constructor( + private readonly rooms: RoomsRepository, + private readonly users: UsersRepository, + private readonly memberships: MembershipsRepository, + private readonly authService: AuthService, + private readonly dispatcher: Dispatcher, + ) {} + + async exec({ + roomId, + authenticatedUser, + email, + }: InviteParams): Promise { + const room = await this.rooms.getRoom(roomId); + + await this.authService.authorize({ + user: authenticatedUser, + action: Role.Manage, + subject: room, + }); + + const invitedUser = await this.users.findUser(email); + + await this.memberships.createMembership({ + userId: invitedUser.id, + roomId, + status: MembershipStatus.PendingInvite, + }); + + const message: DraftMessage = { + content: `${authenticatedUser.name} invited ${invitedUser.name} to join the room`, + roomId: room.id, + authorId: 'system', + }; + + await this.dispatcher.send(message); + } +} From ff78088bad53c9e896480786d994bf086f6d77ff Mon Sep 17 00:00:00 2001 From: John Brunton Date: Thu, 22 Aug 2024 19:51:02 +0100 Subject: [PATCH 02/12] fix: tests --- services/api/package-lock.json | 235 +++++++++--------- services/api/package.json | 4 +- .../api/src/app/messages/messages.module.ts | 2 + services/api/src/app/rooms/rooms.module.ts | 8 +- services/api/src/app/users/users.service.ts | 7 +- services/api/src/data/adapters/schema.ts | 1 + .../api/src/domain/entities/users/system.ts | 4 +- .../src/domain/entities/users/user.entity.ts | 1 + services/api/src/fixtures/auth/FakeAuth.ts | 1 + .../src/fixtures/auth/auth-info.factory.ts | 1 + .../fixtures/data/test.users.repository.ts | 12 + .../api/src/fixtures/messages/user.factory.ts | 1 + 12 files changed, 145 insertions(+), 132 deletions(-) diff --git a/services/api/package-lock.json b/services/api/package-lock.json index 4d4695f4..4669ecc4 100644 --- a/services/api/package-lock.json +++ b/services/api/package-lock.json @@ -64,7 +64,7 @@ "http-server": "14.1.1", "jest": "29.7.0", "jest-fail-on-console": "3.1.1", - "jest-mock-extended": "3.0.1", + "jest-mock-extended": "3.0.7", "prettier": "^2.3.2", "source-map-support": "^0.5.20", "supertest": "^6.1.3", @@ -73,7 +73,7 @@ "ts-node": "^10.0.0", "tsconfig-paths": "4.1.0", "typedoc": "0.26.5", - "typescript": "^4.7.4" + "typescript": "^5.0.0" }, "engines": { "node": "18.x" @@ -3750,35 +3750,36 @@ } }, "node_modules/@nestjs/schematics": { - "version": "9.0.3", - "resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-9.0.3.tgz", - "integrity": "sha512-kZrU/lrpVd2cnK8I3ibDb3Wi1ppl3wX3U3lVWoL+DzRRoezWKkh8upEL4q0koKmuXnsmLiu3UPxFeMOrJV7TSA==", + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-9.2.0.tgz", + "integrity": "sha512-wHpNJDPzM6XtZUOB3gW0J6mkFCSJilzCM3XrHI1o0C8vZmFE1snbmkIXNyoi1eV0Nxh1BMymcgz5vIMJgQtTqw==", "dev": true, + "license": "MIT", "dependencies": { - "@angular-devkit/core": "14.2.1", - "@angular-devkit/schematics": "14.2.1", - "fs-extra": "10.1.0", + "@angular-devkit/core": "16.0.1", + "@angular-devkit/schematics": "16.0.1", "jsonc-parser": "3.2.0", "pluralize": "8.0.0" }, "peerDependencies": { - "typescript": "^4.3.5" + "typescript": ">=4.3.5" } }, "node_modules/@nestjs/schematics/node_modules/@angular-devkit/core": { - "version": "14.2.1", - "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-14.2.1.tgz", - "integrity": "sha512-lW8oNGuJqr4r31FWBjfWQYkSXdiOHBGOThIEtHvUVBKfPF/oVrupLueCUgBPel+NvxENXdo93uPsqHN7bZbmsQ==", + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-16.0.1.tgz", + "integrity": "sha512-2uz98IqkKJlgnHbWQ7VeL4pb+snGAZXIama2KXi+k9GsRntdcw+udX8rL3G9SdUGUF+m6+147Y1oRBMHsO/v4w==", "dev": true, + "license": "MIT", "dependencies": { - "ajv": "8.11.0", + "ajv": "8.12.0", "ajv-formats": "2.1.1", - "jsonc-parser": "3.1.0", - "rxjs": "6.6.7", + "jsonc-parser": "3.2.0", + "rxjs": "7.8.1", "source-map": "0.7.4" }, "engines": { - "node": "^14.15.0 || >=16.10.0", + "node": "^16.14.0 || >=18.10.0", "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", "yarn": ">= 1.13.0" }, @@ -3791,60 +3792,62 @@ } } }, - "node_modules/@nestjs/schematics/node_modules/@angular-devkit/core/node_modules/jsonc-parser": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.1.0.tgz", - "integrity": "sha512-DRf0QjnNeCUds3xTjKlQQ3DpJD51GvDjJfnxUVWg6PZTo2otSm+slzNAxU/35hF8/oJIKoG9slq30JYOsF2azg==", - "dev": true - }, "node_modules/@nestjs/schematics/node_modules/@angular-devkit/schematics": { - "version": "14.2.1", - "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-14.2.1.tgz", - "integrity": "sha512-0U18FwDYt4zROBPrvewH6iBTkf2ozVHN4/gxUb9jWrqVw8mPU5AWc/iYxQLHBSinkr2Egjo1H/i9aBqgJSeh3g==", + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-16.0.1.tgz", + "integrity": "sha512-A9D0LTYmiqiBa90GKcSuWb7hUouGIbm/AHbJbjL85WLLRbQA2PwKl7P5Mpd6nS/ZC0kfG4VQY3VOaDvb3qpI9g==", "dev": true, + "license": "MIT", "dependencies": { - "@angular-devkit/core": "14.2.1", - "jsonc-parser": "3.1.0", - "magic-string": "0.26.2", + "@angular-devkit/core": "16.0.1", + "jsonc-parser": "3.2.0", + "magic-string": "0.30.0", "ora": "5.4.1", - "rxjs": "6.6.7" + "rxjs": "7.8.1" }, "engines": { - "node": "^14.15.0 || >=16.10.0", + "node": "^16.14.0 || >=18.10.0", "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", "yarn": ">= 1.13.0" } }, - "node_modules/@nestjs/schematics/node_modules/@angular-devkit/schematics/node_modules/jsonc-parser": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.1.0.tgz", - "integrity": "sha512-DRf0QjnNeCUds3xTjKlQQ3DpJD51GvDjJfnxUVWg6PZTo2otSm+slzNAxU/35hF8/oJIKoG9slq30JYOsF2azg==", - "dev": true + "node_modules/@nestjs/schematics/node_modules/ajv": { + "version": "8.12.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", + "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } }, "node_modules/@nestjs/schematics/node_modules/jsonc-parser": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.2.0.tgz", "integrity": "sha512-gfFQZrcTc8CnKXp6Y4/CBT3fTc0OVuDofpre4aEeEpSBPV5X5v4+Vmx+8snU7RLPrNHPKSgLxGo9YuQzz20o+w==", - "dev": true + "dev": true, + "license": "MIT" }, - "node_modules/@nestjs/schematics/node_modules/rxjs": { - "version": "6.6.7", - "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-6.6.7.tgz", - "integrity": "sha512-hTdwr+7yYNIT5n4AMYp85KA6yw2Va0FLa3Rguvbpa4W3I5xynaBZo41cM3XM+4Q6fRMj3sBYIR1VAmZMXYJvRQ==", + "node_modules/@nestjs/schematics/node_modules/magic-string": { + "version": "0.30.0", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.0.tgz", + "integrity": "sha512-LA+31JYDJLs82r2ScLrlz1GjSgu66ZV518eyWT+S8VhyQn/JL0u9MeBOvQMGYiPk1DBiSN9DDMOcXvigJZaViQ==", "dev": true, + "license": "MIT", "dependencies": { - "tslib": "^1.9.0" + "@jridgewell/sourcemap-codec": "^1.4.13" }, "engines": { - "npm": ">=2.0.0" + "node": ">=12" } }, - "node_modules/@nestjs/schematics/node_modules/tslib": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", - "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", - "dev": true - }, "node_modules/@nestjs/swagger": { "version": "7.4.0", "resolved": "https://registry.npmjs.org/@nestjs/swagger/-/swagger-7.4.0.tgz", @@ -10129,16 +10132,17 @@ } }, "node_modules/jest-mock-extended": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/jest-mock-extended/-/jest-mock-extended-3.0.1.tgz", - "integrity": "sha512-RF4Ow8pXvbRuEcCTj56oYHmig5311BSFvbEGxPNYL51wGKGu93MvVQgx0UpFmjqyBXIcElkZo2Rke88kR1iSKQ==", + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/jest-mock-extended/-/jest-mock-extended-3.0.7.tgz", + "integrity": "sha512-7lsKdLFcW9B9l5NzZ66S/yTQ9k8rFtnwYdCNuRU/81fqDWicNDVhitTSPnrGmNeNm0xyw0JHexEOShrIKRCIRQ==", "dev": true, + "license": "MIT", "dependencies": { - "ts-essentials": "^7.0.3" + "ts-essentials": "^10.0.0" }, "peerDependencies": { "jest": "^24.0.0 || ^25.0.0 || ^26.0.0 || ^27.0.0 || ^28.0.0 || ^29.0.0", - "typescript": "^3.0.0 || ^4.0.0" + "typescript": "^3.0.0 || ^4.0.0 || ^5.0.0" } }, "node_modules/jest-mock/node_modules/chalk": { @@ -12363,6 +12367,7 @@ "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", "integrity": "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==", "dev": true, + "license": "MIT", "engines": { "node": ">=4" } @@ -13837,12 +13842,18 @@ } }, "node_modules/ts-essentials": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/ts-essentials/-/ts-essentials-7.0.3.tgz", - "integrity": "sha512-8+gr5+lqO3G84KdiTSMRLtuyJ+nTBVRKuCrK4lidMPdVeEp0uqC875uE5NMcaA7YYMN7XsNiFQuMvasF8HT/xQ==", + "version": "10.0.2", + "resolved": "https://registry.npmjs.org/ts-essentials/-/ts-essentials-10.0.2.tgz", + "integrity": "sha512-Xwag0TULqriaugXqVdDiGZ5wuZpqABZlpwQ2Ho4GDyiu/R2Xjkp/9+zcFxL7uzeLl/QCPrflnvpVYyS3ouT7Zw==", "dev": true, + "license": "MIT", "peerDependencies": { - "typescript": ">=3.7.0" + "typescript": ">=4.5.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } } }, "node_modules/ts-jest": { @@ -14253,16 +14264,17 @@ } }, "node_modules/typescript": { - "version": "4.9.4", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.4.tgz", - "integrity": "sha512-Uz+dTXYzxXXbsFpM86Wh3dKCxrQqUcVMxwU54orwlJjOpO3ao8L7j5lH+dWfTwgCwIuM9GQ2kvVotzYJMXTBZg==", + "version": "5.5.4", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.4.tgz", + "integrity": "sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==", "dev": true, + "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" }, "engines": { - "node": ">=4.2.0" + "node": ">=14.17" } }, "node_modules/uc.micro": { @@ -17687,58 +17699,53 @@ } }, "@nestjs/schematics": { - "version": "9.0.3", - "resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-9.0.3.tgz", - "integrity": "sha512-kZrU/lrpVd2cnK8I3ibDb3Wi1ppl3wX3U3lVWoL+DzRRoezWKkh8upEL4q0koKmuXnsmLiu3UPxFeMOrJV7TSA==", + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-9.2.0.tgz", + "integrity": "sha512-wHpNJDPzM6XtZUOB3gW0J6mkFCSJilzCM3XrHI1o0C8vZmFE1snbmkIXNyoi1eV0Nxh1BMymcgz5vIMJgQtTqw==", "dev": true, "requires": { - "@angular-devkit/core": "14.2.1", - "@angular-devkit/schematics": "14.2.1", - "fs-extra": "10.1.0", + "@angular-devkit/core": "16.0.1", + "@angular-devkit/schematics": "16.0.1", "jsonc-parser": "3.2.0", "pluralize": "8.0.0" }, "dependencies": { "@angular-devkit/core": { - "version": "14.2.1", - "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-14.2.1.tgz", - "integrity": "sha512-lW8oNGuJqr4r31FWBjfWQYkSXdiOHBGOThIEtHvUVBKfPF/oVrupLueCUgBPel+NvxENXdo93uPsqHN7bZbmsQ==", + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-16.0.1.tgz", + "integrity": "sha512-2uz98IqkKJlgnHbWQ7VeL4pb+snGAZXIama2KXi+k9GsRntdcw+udX8rL3G9SdUGUF+m6+147Y1oRBMHsO/v4w==", "dev": true, "requires": { - "ajv": "8.11.0", + "ajv": "8.12.0", "ajv-formats": "2.1.1", - "jsonc-parser": "3.1.0", - "rxjs": "6.6.7", + "jsonc-parser": "3.2.0", + "rxjs": "7.8.1", "source-map": "0.7.4" - }, - "dependencies": { - "jsonc-parser": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.1.0.tgz", - "integrity": "sha512-DRf0QjnNeCUds3xTjKlQQ3DpJD51GvDjJfnxUVWg6PZTo2otSm+slzNAxU/35hF8/oJIKoG9slq30JYOsF2azg==", - "dev": true - } } }, "@angular-devkit/schematics": { - "version": "14.2.1", - "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-14.2.1.tgz", - "integrity": "sha512-0U18FwDYt4zROBPrvewH6iBTkf2ozVHN4/gxUb9jWrqVw8mPU5AWc/iYxQLHBSinkr2Egjo1H/i9aBqgJSeh3g==", + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-16.0.1.tgz", + "integrity": "sha512-A9D0LTYmiqiBa90GKcSuWb7hUouGIbm/AHbJbjL85WLLRbQA2PwKl7P5Mpd6nS/ZC0kfG4VQY3VOaDvb3qpI9g==", "dev": true, "requires": { - "@angular-devkit/core": "14.2.1", - "jsonc-parser": "3.1.0", - "magic-string": "0.26.2", + "@angular-devkit/core": "16.0.1", + "jsonc-parser": "3.2.0", + "magic-string": "0.30.0", "ora": "5.4.1", - "rxjs": "6.6.7" - }, - "dependencies": { - "jsonc-parser": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.1.0.tgz", - "integrity": "sha512-DRf0QjnNeCUds3xTjKlQQ3DpJD51GvDjJfnxUVWg6PZTo2otSm+slzNAxU/35hF8/oJIKoG9slq30JYOsF2azg==", - "dev": true - } + "rxjs": "7.8.1" + } + }, + "ajv": { + "version": "8.12.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", + "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", + "dev": true, + "requires": { + "fast-deep-equal": "^3.1.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2", + "uri-js": "^4.2.2" } }, "jsonc-parser": { @@ -17747,20 +17754,14 @@ "integrity": "sha512-gfFQZrcTc8CnKXp6Y4/CBT3fTc0OVuDofpre4aEeEpSBPV5X5v4+Vmx+8snU7RLPrNHPKSgLxGo9YuQzz20o+w==", "dev": true }, - "rxjs": { - "version": "6.6.7", - "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-6.6.7.tgz", - "integrity": "sha512-hTdwr+7yYNIT5n4AMYp85KA6yw2Va0FLa3Rguvbpa4W3I5xynaBZo41cM3XM+4Q6fRMj3sBYIR1VAmZMXYJvRQ==", + "magic-string": { + "version": "0.30.0", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.0.tgz", + "integrity": "sha512-LA+31JYDJLs82r2ScLrlz1GjSgu66ZV518eyWT+S8VhyQn/JL0u9MeBOvQMGYiPk1DBiSN9DDMOcXvigJZaViQ==", "dev": true, "requires": { - "tslib": "^1.9.0" + "@jridgewell/sourcemap-codec": "^1.4.13" } - }, - "tslib": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", - "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", - "dev": true } } }, @@ -22538,12 +22539,12 @@ } }, "jest-mock-extended": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/jest-mock-extended/-/jest-mock-extended-3.0.1.tgz", - "integrity": "sha512-RF4Ow8pXvbRuEcCTj56oYHmig5311BSFvbEGxPNYL51wGKGu93MvVQgx0UpFmjqyBXIcElkZo2Rke88kR1iSKQ==", + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/jest-mock-extended/-/jest-mock-extended-3.0.7.tgz", + "integrity": "sha512-7lsKdLFcW9B9l5NzZ66S/yTQ9k8rFtnwYdCNuRU/81fqDWicNDVhitTSPnrGmNeNm0xyw0JHexEOShrIKRCIRQ==", "dev": true, "requires": { - "ts-essentials": "^7.0.3" + "ts-essentials": "^10.0.0" } }, "jest-pnp-resolver": { @@ -25351,9 +25352,9 @@ "integrity": "sha512-8CYSLazCyj0DJDpPIxOFzJG46r93uh6EynYjuey+bxcLltBeqZL7DMfaE5ZPzZNFlav7wx+2TDa/mBl8gkTYzw==" }, "ts-essentials": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/ts-essentials/-/ts-essentials-7.0.3.tgz", - "integrity": "sha512-8+gr5+lqO3G84KdiTSMRLtuyJ+nTBVRKuCrK4lidMPdVeEp0uqC875uE5NMcaA7YYMN7XsNiFQuMvasF8HT/xQ==", + "version": "10.0.2", + "resolved": "https://registry.npmjs.org/ts-essentials/-/ts-essentials-10.0.2.tgz", + "integrity": "sha512-Xwag0TULqriaugXqVdDiGZ5wuZpqABZlpwQ2Ho4GDyiu/R2Xjkp/9+zcFxL7uzeLl/QCPrflnvpVYyS3ouT7Zw==", "dev": true, "requires": {} }, @@ -25629,9 +25630,9 @@ } }, "typescript": { - "version": "4.9.4", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.4.tgz", - "integrity": "sha512-Uz+dTXYzxXXbsFpM86Wh3dKCxrQqUcVMxwU54orwlJjOpO3ao8L7j5lH+dWfTwgCwIuM9GQ2kvVotzYJMXTBZg==", + "version": "5.5.4", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.4.tgz", + "integrity": "sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==", "dev": true }, "uc.micro": { diff --git a/services/api/package.json b/services/api/package.json index 34091766..13f8d982 100644 --- a/services/api/package.json +++ b/services/api/package.json @@ -85,7 +85,7 @@ "http-server": "14.1.1", "jest": "29.7.0", "jest-fail-on-console": "3.1.1", - "jest-mock-extended": "3.0.1", + "jest-mock-extended": "3.0.7", "prettier": "^2.3.2", "source-map-support": "^0.5.20", "supertest": "^6.1.3", @@ -94,6 +94,6 @@ "ts-node": "^10.0.0", "tsconfig-paths": "4.1.0", "typedoc": "0.26.5", - "typescript": "^4.7.4" + "typescript": "^5.0.0" } } diff --git a/services/api/src/app/messages/messages.module.ts b/services/api/src/app/messages/messages.module.ts index 4065e3dd..a4eb00f8 100644 --- a/services/api/src/app/messages/messages.module.ts +++ b/services/api/src/app/messages/messages.module.ts @@ -13,6 +13,7 @@ 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'; +import { InviteUseCase } from '@usecases/rooms/invite'; @Module({ imports: [AuthModule, DispatcherModule], @@ -30,6 +31,7 @@ import { ChangeRoomJoinPolicyUseCase } from '@usecases/rooms/change-room-join-po RenameRoomUseCase, RenameUserUseCase, ChangeRoomJoinPolicyUseCase, + InviteUseCase, LoremCommandUseCase, HelpCommandUseCase, ], diff --git a/services/api/src/app/rooms/rooms.module.ts b/services/api/src/app/rooms/rooms.module.ts index f719eda4..3df1746f 100644 --- a/services/api/src/app/rooms/rooms.module.ts +++ b/services/api/src/app/rooms/rooms.module.ts @@ -6,17 +6,11 @@ import { JoinRoomUseCase } from '@usecases/rooms/join'; import { RoomsController } from './rooms.controller'; import { MessagesModule } from '@app/messages/messages.module'; import { DispatcherModule } from '@app/dispatcher/dispatcher.module'; -import { InviteUseCase } from '@usecases/rooms/invite'; @Module({ imports: [AuthModule, DispatcherModule, MessagesModule], controllers: [RoomsController], - providers: [ - CreateRoomUseCase, - GetRoomUseCase, - JoinRoomUseCase, - InviteUseCase, - ], + providers: [CreateRoomUseCase, GetRoomUseCase, JoinRoomUseCase], exports: [], }) export class RoomsModule {} diff --git a/services/api/src/app/users/users.service.ts b/services/api/src/app/users/users.service.ts index 54ffa5be..8f89d8cc 100644 --- a/services/api/src/app/users/users.service.ts +++ b/services/api/src/app/users/users.service.ts @@ -2,7 +2,7 @@ import { MembershipStatus } from '@entities/membership.entity'; import { MembershipsRepository } from '@entities/memberships.repository'; import { Room } from '@entities/room.entity'; import { RoomsRepository } from '@entities/rooms.repository'; -import { User } from '@entities/users'; +import { User, systemUser } from '@entities/users'; import { UsersRepository } from '@entities/users'; import { Injectable } from '@nestjs/common'; import { filter, pluck, uniq } from 'rambda'; @@ -17,10 +17,7 @@ export class UsersService { async getUser(userId: string): Promise { if (userId === 'system') { - return { - id: 'system', - name: 'System', - }; + return systemUser; } return await this.usersRepo.getUser(userId); } diff --git a/services/api/src/data/adapters/schema.ts b/services/api/src/data/adapters/schema.ts index a93868b3..35af9673 100644 --- a/services/api/src/data/adapters/schema.ts +++ b/services/api/src/data/adapters/schema.ts @@ -59,6 +59,7 @@ export const DbSchema = { enum: [ MembershipStatus.None, MembershipStatus.Joined, + MembershipStatus.PendingInvite, MembershipStatus.PendingApproval, MembershipStatus.Revoked, ], diff --git a/services/api/src/domain/entities/users/system.ts b/services/api/src/domain/entities/users/system.ts index 03501ac3..e19834b9 100644 --- a/services/api/src/domain/entities/users/system.ts +++ b/services/api/src/domain/entities/users/system.ts @@ -1,8 +1,10 @@ import { SentMessage, DraftMessage } from '../messages/message'; +import { User } from './user.entity'; -export const systemUser = Object.freeze({ +export const systemUser: User = Object.freeze({ id: 'system', name: 'System', + email: 'system@example.com', }); export const isSystemMessage = (message: SentMessage): boolean => { diff --git a/services/api/src/domain/entities/users/user.entity.ts b/services/api/src/domain/entities/users/user.entity.ts index 62dee3c5..e76ebdaa 100644 --- a/services/api/src/domain/entities/users/user.entity.ts +++ b/services/api/src/domain/entities/users/user.entity.ts @@ -1,5 +1,6 @@ export class User { id: string; name: string; + email: string; picture?: string; } diff --git a/services/api/src/fixtures/auth/FakeAuth.ts b/services/api/src/fixtures/auth/FakeAuth.ts index 3b879648..3c7f4495 100644 --- a/services/api/src/fixtures/auth/FakeAuth.ts +++ b/services/api/src/fixtures/auth/FakeAuth.ts @@ -32,6 +32,7 @@ export const fakeAuthUser = ( const user: User = { id: `user:${userParams.sub}`, name: userParams.name, + email: userParams.email, picture: userParams.picture, }; const fakeAuth = { diff --git a/services/api/src/fixtures/auth/auth-info.factory.ts b/services/api/src/fixtures/auth/auth-info.factory.ts index e4034bc8..34f9d3d4 100644 --- a/services/api/src/fixtures/auth/auth-info.factory.ts +++ b/services/api/src/fixtures/auth/auth-info.factory.ts @@ -5,6 +5,7 @@ export const AuthInfoFactory = { build: (overrides?: Partial): AuthInfo => ({ sub: overrides?.sub ?? `google-${faker.datatype.uuid()}`, name: overrides?.name ?? faker.name.fullName(), + email: overrides?.email ?? faker.internet.email(), picture: overrides?.picture ?? faker.internet.avatar(), }), }; diff --git a/services/api/src/fixtures/data/test.users.repository.ts b/services/api/src/fixtures/data/test.users.repository.ts index fa892a2e..5055a44c 100644 --- a/services/api/src/fixtures/data/test.users.repository.ts +++ b/services/api/src/fixtures/data/test.users.repository.ts @@ -16,9 +16,13 @@ export class TestUsersRepository extends UsersRepository { } override async saveUser(params: AuthInfo): Promise { + if (!params.email) { + throw new Error('Requires email'); + } const user = { id: `user:${params.sub}`, name: params.name ?? 'Anon', + email: params.email, picture: params.picture, }; this.users.push(user); @@ -33,6 +37,14 @@ export class TestUsersRepository extends UsersRepository { return user; } + override async findUser(email: string): Promise { + const user = find((user) => email === user.email, this.users); + if (!user) { + throw new NotFoundException(`User with email ${email} does not exist`); + } + return user; + } + override async updateUser(params: UpdateUserParams): Promise { const user = find((user) => params.id === user.id, this.users); if (!user) { diff --git a/services/api/src/fixtures/messages/user.factory.ts b/services/api/src/fixtures/messages/user.factory.ts index cb241311..e026ee19 100644 --- a/services/api/src/fixtures/messages/user.factory.ts +++ b/services/api/src/fixtures/messages/user.factory.ts @@ -6,6 +6,7 @@ export const UserFactory = { build: (overrides?: Partial): User => ({ id: overrides?.id ?? UserFactory.id(), name: overrides?.name ?? faker.name.fullName(), + email: overrides?.email ?? faker.internet.email(), picture: overrides?.picture ?? faker.internet.avatar(), }), }; From 931e919c2a367e5c5c961a08ed481dd92888c92f Mon Sep 17 00:00:00 2001 From: John Brunton Date: Thu, 22 Aug 2024 20:05:20 +0100 Subject: [PATCH 03/12] fix: tests --- .../data/repositories/dynamodb.users.repository.e2e-spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/services/api/src/data/repositories/dynamodb.users.repository.e2e-spec.ts b/services/api/src/data/repositories/dynamodb.users.repository.e2e-spec.ts index 0c61d2fe..109bb86d 100644 --- a/services/api/src/data/repositories/dynamodb.users.repository.e2e-spec.ts +++ b/services/api/src/data/repositories/dynamodb.users.repository.e2e-spec.ts @@ -59,8 +59,8 @@ describe('RoomsRepository', () => { test.each(testCases)('[$name] updates users', async ({ name }) => { const repo = repos[name]; const params: SaveUserParams = { - name: 'Some User', - email: 'some.user@example.com', + name: 'Other User', + email: 'other.user@example.com', sub: 'user:google_123', }; const user = await repo.saveUser(params); From 667ee5a7fba0265ba5c7cb29c3df6b3474d7150a Mon Sep 17 00:00:00 2001 From: John Brunton Date: Thu, 22 Aug 2024 20:29:38 +0100 Subject: [PATCH 04/12] feat: error cases --- .../src/domain/entities/membership.entity.ts | 31 ++++++++++++++++++ .../domain/entities/memberships.repository.ts | 1 + .../usecases/commands/parse/parsers/index.ts | 2 ++ .../commands/parse/parsers/invite-parser.ts | 10 +++--- .../api/src/domain/usecases/rooms/invite.ts | 32 ++++++++++++++++++- 5 files changed, 70 insertions(+), 6 deletions(-) diff --git a/services/api/src/domain/entities/membership.entity.ts b/services/api/src/domain/entities/membership.entity.ts index a17524b1..17b56298 100644 --- a/services/api/src/domain/entities/membership.entity.ts +++ b/services/api/src/domain/entities/membership.entity.ts @@ -1,3 +1,5 @@ +import { isNil } from 'rambda'; + /** * The status of a user's membership to a room. */ @@ -59,3 +61,32 @@ export type Membership = { */ until?: number; }; + +export const isCurrent = + (status: MembershipStatus) => (roomId: string) => (membership: Membership) => + isNil(membership.until) && + membership.status === status && + membership.roomId === roomId; + +export const isActive = isCurrent(MembershipStatus.Joined); + +export const isMemberOf = (roomId: string, memberships: Membership[]) => + memberships.some(isActive(roomId)); + +export const hasInviteTo = (roomId: string, memberships: Membership[]) => + memberships.some(isCurrent(MembershipStatus.PendingInvite)(roomId)); + +// export const isCurrent = ( +// membership: Membership, +// status?: MembershipStatus, +// roomId?: string, +// ) => +// isNil(membership.until) && +// (isNil(status) || membership.status === status) && +// (isNil(roomId) || membership.roomId == roomId); + +// export const isActive = (membership: Membership, roomId?: string) => +// isCurrent(membership, MembershipStatus.Joined, roomId); + +// export const isMemberOf = (roomId: string, memberships: Membership[]) => +// memberships.some((membership) => isActive(membership, roomId)); diff --git a/services/api/src/domain/entities/memberships.repository.ts b/services/api/src/domain/entities/memberships.repository.ts index f6217108..8142ff38 100644 --- a/services/api/src/domain/entities/memberships.repository.ts +++ b/services/api/src/domain/entities/memberships.repository.ts @@ -11,5 +11,6 @@ export abstract class MembershipsRepository { abstract createMembership( params: CreateMembershipParams, ): Promise; + abstract getMemberships(userId: string): Promise; } 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 316ff6d6..b38a785c 100644 --- a/services/api/src/domain/usecases/commands/parse/parsers/index.ts +++ b/services/api/src/domain/usecases/commands/parse/parsers/index.ts @@ -1,6 +1,7 @@ import { CommandParser } from '../command.parser'; import { changeRoomJoinPolicyParser } from './change-room-join-policy-parser'; import { helpParser } from './help.parser'; +import { inviteParser } from './invite-parser'; import { loremParser } from './lorem.parser'; import { renameRoomParser } from './rename.room.parser'; import { renameUserParser } from './rename.user.parser'; @@ -11,4 +12,5 @@ export const parsers: CommandParser[] = [ renameRoomParser, renameUserParser, changeRoomJoinPolicyParser, + inviteParser, ]; diff --git a/services/api/src/domain/usecases/commands/parse/parsers/invite-parser.ts b/services/api/src/domain/usecases/commands/parse/parsers/invite-parser.ts index a9ca42dd..f563f20c 100644 --- a/services/api/src/domain/usecases/commands/parse/parsers/invite-parser.ts +++ b/services/api/src/domain/usecases/commands/parse/parsers/invite-parser.ts @@ -2,15 +2,15 @@ import { z } from 'zod'; import { CommandParser, ParsedCommand } from '../command.parser'; const schema = z - .tuple([z.literal('invite'), z.literal('user'), z.string()]) - .transform(([, , email]) => ({ + .tuple([z.literal('invite'), z.string()]) + .transform(([, email]) => ({ tag: 'inviteUser', params: { email }, })); -export const changeRoomJoinPolicyParser = new CommandParser({ - matchTokens: ['invite', 'user'], +export const inviteParser = new CommandParser({ + matchTokens: ['invite'], schema, - signature: `/invite user {email}`, + signature: `/invite {email}`, summary: 'invite a user to the room', }); diff --git a/services/api/src/domain/usecases/rooms/invite.ts b/services/api/src/domain/usecases/rooms/invite.ts index ec7dd9ba..c049f292 100644 --- a/services/api/src/domain/usecases/rooms/invite.ts +++ b/services/api/src/domain/usecases/rooms/invite.ts @@ -1,5 +1,10 @@ import { AuthService, Role } from '@usecases/auth.service'; -import { MembershipStatus } from '@entities/membership.entity'; +import { + MembershipStatus, + hasInviteTo, + isActive, + isMemberOf, +} from '@entities/membership.entity'; import { MembershipsRepository } from '@entities/memberships.repository'; import { RoomsRepository } from '@entities/rooms.repository'; import { User, UsersRepository } from '@entities/users'; @@ -37,6 +42,31 @@ export class InviteUseCase { const invitedUser = await this.users.findUser(email); + 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 (hasInviteTo(roomId, existingMemberships)) { + const message: DraftMessage = { + content: `${invitedUser.name} already has an invite to this room`, + roomId: room.id, + authorId: 'system', + recipientId: authenticatedUser.id, + }; + + await this.dispatcher.send(message); + return; + } + await this.memberships.createMembership({ userId: invitedUser.id, roomId, From 43e28c6aae1bad94dcd1e0cecc9557ccfd2f8d44 Mon Sep 17 00:00:00 2001 From: John Brunton Date: Fri, 23 Aug 2024 18:19:13 +0100 Subject: [PATCH 05/12] feat: leave use case --- client/src/data/messages.ts | 1 + .../api/src/app/messages/command.service.ts | 8 ++++ .../api/src/app/messages/messages.module.ts | 2 + .../usecases/commands/parse/command.parser.ts | 1 + .../usecases/commands/parse/parsers/index.ts | 2 + .../commands/parse/parsers/leave.parser.ts | 14 ++++++ .../api/src/domain/usecases/rooms/leave.ts | 44 +++++++++++++++++++ 7 files changed, 72 insertions(+) create mode 100644 services/api/src/domain/usecases/commands/parse/parsers/leave.parser.ts create mode 100644 services/api/src/domain/usecases/rooms/leave.ts diff --git a/client/src/data/messages.ts b/client/src/data/messages.ts index 586cd8d1..cb83dd49 100644 --- a/client/src/data/messages.ts +++ b/client/src/data/messages.ts @@ -55,6 +55,7 @@ export const useMessagesSubscription = (roomId?: string, opts: QueryOptions = De } if (message.updatedEntities?.includes('users')) { queryClient.invalidateQueries({ queryKey: ['users'] }) + queryClient.invalidateQueries({ queryKey: ['me'] }) } queryClient.setQueryData(['messages', message.roomId], (messages: Message[] | undefined) => { if (!messages) return diff --git a/services/api/src/app/messages/command.service.ts b/services/api/src/app/messages/command.service.ts index aeb56111..93aa1e29 100644 --- a/services/api/src/app/messages/command.service.ts +++ b/services/api/src/app/messages/command.service.ts @@ -10,6 +10,7 @@ import { ParseCommandUseCase } from '@usecases/commands/parse'; import { ChangeRoomJoinPolicyUseCase } from '@usecases/rooms/change-room-join-policy'; import { P, match } from 'ts-pattern'; import { InviteUseCase } from '@usecases/rooms/invite'; +import { LeaveRoomUseCase } from '@usecases/rooms/leave'; @Injectable() export class CommandService { @@ -18,6 +19,7 @@ export class CommandService { private readonly renameRoom: RenameRoomUseCase, private readonly lorem: LoremCommandUseCase, private readonly help: HelpCommandUseCase, + private readonly leave: LeaveRoomUseCase, private readonly parse: ParseCommandUseCase, private readonly changeRoomJoinPolicy: ChangeRoomJoinPolicyUseCase, private readonly invite: InviteUseCase, @@ -47,6 +49,12 @@ export class CommandService { authenticatedUser, }), ) + .with({ tag: 'leave' }, () => + this.leave.exec({ + roomId, + authenticatedUser, + }), + ) .with({ tag: 'inviteUser', params: P.select() }, (params) => { this.invite.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 a4eb00f8..578feba3 100644 --- a/services/api/src/app/messages/messages.module.ts +++ b/services/api/src/app/messages/messages.module.ts @@ -14,6 +14,7 @@ import { FakerLoremGenerator } from './faker.lorem.generator'; import { DispatcherModule } from '../dispatcher/dispatcher.module'; import { ChangeRoomJoinPolicyUseCase } from '@usecases/rooms/change-room-join-policy'; import { InviteUseCase } from '@usecases/rooms/invite'; +import { LeaveRoomUseCase } from '@usecases/rooms/leave'; @Module({ imports: [AuthModule, DispatcherModule], @@ -32,6 +33,7 @@ import { InviteUseCase } from '@usecases/rooms/invite'; RenameUserUseCase, ChangeRoomJoinPolicyUseCase, InviteUseCase, + LeaveRoomUseCase, LoremCommandUseCase, HelpCommandUseCase, ], 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 78562a9f..55069e3d 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: 'leave'; params: null } | { tag: 'changeRoomJoinPolicy'; params: { newJoinPolicy: JoinPolicy } } | { tag: 'lorem'; 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 b38a785c..4b7a7a4d 100644 --- a/services/api/src/domain/usecases/commands/parse/parsers/index.ts +++ b/services/api/src/domain/usecases/commands/parse/parsers/index.ts @@ -2,6 +2,7 @@ import { CommandParser } from '../command.parser'; import { changeRoomJoinPolicyParser } from './change-room-join-policy-parser'; import { helpParser } from './help.parser'; import { inviteParser } from './invite-parser'; +import { leaveParser } from './leave.parser'; import { loremParser } from './lorem.parser'; import { renameRoomParser } from './rename.room.parser'; import { renameUserParser } from './rename.user.parser'; @@ -11,6 +12,7 @@ export const parsers: CommandParser[] = [ loremParser, renameRoomParser, renameUserParser, + leaveParser, changeRoomJoinPolicyParser, inviteParser, ]; diff --git a/services/api/src/domain/usecases/commands/parse/parsers/leave.parser.ts b/services/api/src/domain/usecases/commands/parse/parsers/leave.parser.ts new file mode 100644 index 00000000..e11e4424 --- /dev/null +++ b/services/api/src/domain/usecases/commands/parse/parsers/leave.parser.ts @@ -0,0 +1,14 @@ +import { z } from 'zod'; +import { CommandParser, ParsedCommand } from '../command.parser'; + +const schema = z.tuple([z.literal('leave')]).transform(() => ({ + tag: 'leave', + params: null, +})); + +export const leaveParser = new CommandParser({ + matchTokens: ['leave'], + schema, + signature: '/leave', + summary: 'leave room', +}); diff --git a/services/api/src/domain/usecases/rooms/leave.ts b/services/api/src/domain/usecases/rooms/leave.ts new file mode 100644 index 00000000..ed55b782 --- /dev/null +++ b/services/api/src/domain/usecases/rooms/leave.ts @@ -0,0 +1,44 @@ +import { AuthService, Role } from '@usecases/auth.service'; +import { MembershipStatus, isMemberOf } from '@entities/membership.entity'; +import { MembershipsRepository } from '@entities/memberships.repository'; +import { RoomsRepository } from '@entities/rooms.repository'; +import { User } from '@entities/users'; +import { Injectable } from '@nestjs/common'; +import { + Dispatcher, + DraftMessage, + UpdatedEntity, +} from '@entities/messages/message'; + +export type LeaveRoomParams = { + roomId: string; + authenticatedUser: User; +}; + +@Injectable() +export class LeaveRoomUseCase { + constructor( + private readonly rooms: RoomsRepository, + private readonly memberships: MembershipsRepository, + private readonly dispatcher: Dispatcher, + ) {} + + async exec({ roomId, authenticatedUser }: LeaveRoomParams): Promise { + const room = await this.rooms.getRoom(roomId); + + await this.memberships.createMembership({ + userId: authenticatedUser.id, + roomId, + status: MembershipStatus.Revoked, + }); + + const message: DraftMessage = { + content: `${authenticatedUser.name} left the room.`, + roomId: room.id, + authorId: 'system', + updatedEntities: [UpdatedEntity.Room, UpdatedEntity.Users], + }; + + await this.dispatcher.send(message); + } +} From 11d6b5d28d032898eb278ceee51e24e77c384ea0 Mon Sep 17 00:00:00 2001 From: John Brunton Date: Fri, 23 Aug 2024 20:48:15 +0100 Subject: [PATCH 06/12] fix: typechecker assistance --- client/src/features/room/organisms/ChatBox.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/src/features/room/organisms/ChatBox.tsx b/client/src/features/room/organisms/ChatBox.tsx index 2c398024..102be544 100644 --- a/client/src/features/room/organisms/ChatBox.tsx +++ b/client/src/features/room/organisms/ChatBox.tsx @@ -1,5 +1,5 @@ import { Button, Icon, Textarea, Spinner, VStack, Alert, AlertIcon, Spacer } from '@chakra-ui/react' -import React, { useState, KeyboardEventHandler, useRef, useEffect } from 'react' +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' @@ -40,7 +40,7 @@ const JoinAlert = ({ roomId, canJoin }: ChatBoxProps) => { ) } -export const ChatBox: React.FC = ({ roomId, canJoin }: ChatBoxProps) => { +export const ChatBox: React.FC = ({ roomId, canJoin }: ChatBoxProps): ReactElement => { const [content, setContent] = useState('') const { data: user, isLoading } = useUserDetails() const joined = user?.rooms.some((room) => room.id === roomId) From ea98067878e4884565f04ce888f0c7b21edb9fcb Mon Sep 17 00:00:00 2001 From: John Brunton Date: Fri, 23 Aug 2024 20:58:23 +0100 Subject: [PATCH 07/12] fix: join permissions --- client/src/features/room/organisms/ChatBox.tsx | 2 +- services/api/src/app/auth/permissions/roles.ts | 5 ++++- services/api/src/domain/entities/membership.entity.ts | 3 ++- services/api/src/domain/usecases/rooms/invite.ts | 1 - services/api/src/domain/usecases/rooms/leave.ts | 3 +-- 5 files changed, 8 insertions(+), 6 deletions(-) diff --git a/client/src/features/room/organisms/ChatBox.tsx b/client/src/features/room/organisms/ChatBox.tsx index 102be544..7dfbab64 100644 --- a/client/src/features/room/organisms/ChatBox.tsx +++ b/client/src/features/room/organisms/ChatBox.tsx @@ -23,7 +23,7 @@ const JoinAlert = ({ roomId, canJoin }: ChatBoxProps) => { return ( - You need to join this room to chat. + Join this room to chat.