-
Notifications
You must be signed in to change notification settings - Fork 0
[TM-3027] start up endpoint to send invitation to join organization u… #585
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: release/youthful-yttrofluorite
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,22 @@ | ||
| import { ApiProperty } from "@nestjs/swagger"; | ||
| import { IsEmail, IsOptional, IsString, MaxLength, MinLength } from "class-validator"; | ||
|
|
||
| export class OrganisationInviteRequestDto { | ||
| @IsEmail() | ||
| @MinLength(1) | ||
| @MaxLength(255) | ||
| @ApiProperty({ | ||
| description: "Email address to invite to the organisation.", | ||
| required: true, | ||
| maxLength: 255 | ||
| }) | ||
| emailAddress: string; | ||
|
|
||
| @IsOptional() | ||
| @IsString() | ||
| @ApiProperty({ | ||
| description: "Optional callback URL base for the signup link in the email.", | ||
| required: false | ||
| }) | ||
| callbackUrl?: string | null; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -8,7 +8,8 @@ import { | |
| Param, | ||
| Post, | ||
| Query, | ||
| UnauthorizedException | ||
| UnauthorizedException, | ||
| UnprocessableEntityException | ||
| } from "@nestjs/common"; | ||
| import { UserAssociationService } from "./user-association.service"; | ||
| import { ApiOperation } from "@nestjs/swagger"; | ||
|
|
@@ -20,6 +21,8 @@ import { PolicyService } from "@terramatch-microservices/common"; | |
| import { UserAssociationQueryDto } from "./dto/user-association-query.dto"; | ||
| import { UserAssociationDeleteQueryDto } from "./dto/user-association-delete-query.dto"; | ||
| import { UserAssociationModelParamDto } from "./dto/user-association-model.dto"; | ||
| import { OrganisationInviteRequestDto } from "./dto/organisation-invite-request.dto"; | ||
| import { OrganisationInviteDto } from "@terramatch-microservices/common/dto"; | ||
|
|
||
| @Controller("userAssociations/v3/:model") | ||
| export class UserAssociationController { | ||
|
|
@@ -70,6 +73,43 @@ export class UserAssociationController { | |
| return document; | ||
| } | ||
|
|
||
| @Post(":uuid/invite") | ||
| @ApiOperation({ | ||
| operationId: "inviteOrganisationUser", | ||
| summary: "Invite a new user to join an organisation" | ||
| }) | ||
| @JsonApiResponse({ data: OrganisationInviteDto }) | ||
| @ExceptionResponse(UnauthorizedException, { | ||
| description: "Authentication failed, or resource unavailable to current user." | ||
| }) | ||
| @ExceptionResponse(NotFoundException, { description: "Organisation not found" }) | ||
| @ExceptionResponse(BadRequestException, { description: "Request params are malformed." }) | ||
| @ExceptionResponse(UnprocessableEntityException, { | ||
| description: "A user with this email already exists." | ||
| }) | ||
| async inviteOrganisationUser( | ||
| @Param() { model, uuid }: UserAssociationModelParamDto, | ||
| @Body() body: OrganisationInviteRequestDto | ||
| ) { | ||
| if (model !== "organisations") { | ||
| throw new BadRequestException("Invites are only supported for organisations"); | ||
| } | ||
|
|
||
| const processor = this.userAssociationService.createProcessor(model, uuid); | ||
| const organisation = await processor.getEntity(); | ||
| await this.policyService.authorize("update", organisation); | ||
|
|
||
| const invite = await this.userAssociationService.inviteOrganisationUser( | ||
| organisation as never, | ||
| body.emailAddress, | ||
| body.callbackUrl | ||
| ); | ||
|
|
||
| const document = buildJsonApi(OrganisationInviteDto); | ||
| document.addData(invite.uuid, new OrganisationInviteDto(invite)); | ||
| return document; | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎨 you can streamline this a little and just return the result of |
||
| } | ||
|
|
||
| @Delete(":uuid") | ||
| @ApiOperation({ | ||
| operationId: "deleteUserAssociation", | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -22,9 +22,15 @@ import { | |
| Organisation, | ||
| Project, | ||
| Role, | ||
| ModelHasRole | ||
| ModelHasRole, | ||
| OrganisationInvite | ||
| } from "@terramatch-microservices/database/entities"; | ||
| import { NotFoundException, UnauthorizedException, BadRequestException } from "@nestjs/common"; | ||
| import { | ||
| NotFoundException, | ||
| UnauthorizedException, | ||
| BadRequestException, | ||
| UnprocessableEntityException | ||
| } from "@nestjs/common"; | ||
| import { DocumentBuilder } from "@terramatch-microservices/common/util"; | ||
|
|
||
| describe("UserAssociationService", () => { | ||
|
|
@@ -826,4 +832,56 @@ describe("UserAssociationService", () => { | |
| expect(result).toEqual(user); | ||
| }); | ||
| }); | ||
|
|
||
| describe("inviteOrganisationUser", () => { | ||
| it("should throw UnprocessableEntityException when user with email already exists", async () => { | ||
| const org = await OrganisationFactory.create(); | ||
| const user = await UserFactory.create({ emailAddress: "[email protected]" }); | ||
|
|
||
| jest.spyOn(User, "findOne").mockResolvedValue(user); | ||
|
|
||
| await expect(service.inviteOrganisationUser(org, "[email protected]")).rejects.toThrow( | ||
| UnprocessableEntityException | ||
| ); | ||
| }); | ||
|
|
||
| it("should create user, organisation invite and queue email when user does not exist", async () => { | ||
| const org = await OrganisationFactory.create(); | ||
| const role = await RoleFactory.create({ name: "project-developer" }); | ||
| const newUser = await UserFactory.create({ organisationId: org.id, emailAddress: "[email protected]" }); | ||
| const invite = { | ||
| id: 1, | ||
| uuid: "invite-uuid", | ||
| organisationId: org.id, | ||
| emailAddress: "[email protected]", | ||
| token: "fake-token", | ||
| acceptedAt: null, | ||
| createdAt: new Date() | ||
| } as OrganisationInvite; | ||
|
|
||
| jest.spyOn(User, "findOne").mockResolvedValue(null); | ||
| jest.spyOn(User, "create").mockResolvedValue(newUser); | ||
| jest.spyOn(Role, "findOne").mockResolvedValue(role); | ||
| jest.spyOn(ModelHasRole, "create").mockResolvedValue({} as ModelHasRole); | ||
| jwtService.signAsync.mockResolvedValue("fake-token"); | ||
| jest.spyOn(OrganisationInvite, "create").mockResolvedValue(invite); | ||
| emailQueue.add = jest.fn().mockResolvedValue({} as Job); | ||
|
|
||
| const result = await service.inviteOrganisationUser(org, "[email protected]", "http://frontend/auth/signup"); | ||
|
|
||
| expect(User.create).toHaveBeenCalled(); | ||
| expect(Role.findOne).toHaveBeenCalledWith({ where: { name: "project-developer" } }); | ||
| expect(ModelHasRole.create).toHaveBeenCalled(); | ||
| expect(jwtService.signAsync).toHaveBeenCalled(); | ||
| expect(OrganisationInvite.create).toHaveBeenCalledWith( | ||
| expect.objectContaining({ | ||
| organisationId: org.id, | ||
| emailAddress: "[email protected]", | ||
| token: "fake-token" | ||
| }) | ||
| ); | ||
| expect(emailQueue.add).toHaveBeenCalled(); | ||
| expect(result).toBe(invite); | ||
| }); | ||
| }); | ||
| }); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,4 +1,10 @@ | ||
| import { BadRequestException, Injectable, NotFoundException, UnauthorizedException } from "@nestjs/common"; | ||
| import { | ||
| BadRequestException, | ||
| Injectable, | ||
| NotFoundException, | ||
| UnauthorizedException, | ||
| UnprocessableEntityException | ||
| } from "@nestjs/common"; | ||
| import { | ||
| ModelHasRole, | ||
| Notification, | ||
|
|
@@ -8,7 +14,8 @@ import { | |
| ProjectInvite, | ||
| ProjectUser, | ||
| Role, | ||
| User | ||
| User, | ||
| OrganisationInvite | ||
| } from "@terramatch-microservices/database/entities"; | ||
| import { FindOptions, Op, WhereOptions } from "sequelize"; | ||
| import { UserAssociationCreateAttributes, UserAssociationCreateBody } from "./dto/user-association-create.dto"; | ||
|
|
@@ -21,6 +28,7 @@ import { Queue } from "bullmq"; | |
| import { ProjectInviteEmail } from "@terramatch-microservices/common/email/project-invite.email"; | ||
| import { ProjectMonitoringNotificationEmail } from "@terramatch-microservices/common/email/project-monitoring-notification.email"; | ||
| import { OrganisationJoinRequestEmail } from "@terramatch-microservices/common/email/organisation-join-request.email"; | ||
| import { OrganisationInviteEmail } from "@terramatch-microservices/common/email/organisation-invite.email"; | ||
| import { TMLogger } from "@terramatch-microservices/common/util/tm-logger"; | ||
| import { isNotNull } from "@terramatch-microservices/database/types/array"; | ||
| import { keyBy } from "lodash"; | ||
|
|
@@ -302,6 +310,57 @@ export class UserAssociationService { | |
| return users.map(user => user.uuid); | ||
| } | ||
|
|
||
| async inviteOrganisationUser( | ||
| organisation: Organisation, | ||
| emailAddress: string, | ||
| callbackUrl?: string | null | ||
| ): Promise<OrganisationInvite> { | ||
| const existingUser = await User.findOne({ | ||
| where: { emailAddress }, | ||
| attributes: ["id"] | ||
| }); | ||
| if (existingUser != null) { | ||
| throw new UnprocessableEntityException("User is already a part of this programme"); | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This error message is misleading - all you've determined here is that the user already exists. |
||
| } | ||
|
|
||
| const newUser = await User.create({ | ||
| organisationId: organisation.id, | ||
| emailAddress, | ||
| password: crypto.randomBytes(32).toString("hex"), | ||
| locale: "en-US" | ||
| } as User); | ||
|
|
||
| const pdRole = (await Role.findOne({ where: { name: "project-developer" } })) as Role; | ||
| await ModelHasRole.create({ | ||
| modelId: newUser.id, | ||
| roleId: pdRole.id, | ||
| modelType: User.LARAVEL_TYPE | ||
| } as ModelHasRole); | ||
|
|
||
| const token = await this.jwtService.signAsync({ sub: newUser.uuid }, { expiresIn: "7d" }); | ||
| const invite = await OrganisationInvite.create({ | ||
| organisationId: organisation.id, | ||
| emailAddress, | ||
| token | ||
| } as OrganisationInvite); | ||
|
|
||
| try { | ||
| await new OrganisationInviteEmail({ | ||
| organisationId: organisation.id, | ||
| emailAddress, | ||
| token, | ||
| callbackUrl | ||
| }).sendLater(this.emailQueue); | ||
| } catch (error) { | ||
| this.logger.error( | ||
| `Failed to queue organisation invite email for organisation ${organisation.id} and email ${emailAddress}`, | ||
| error | ||
| ); | ||
| } | ||
|
|
||
| return invite; | ||
| } | ||
|
|
||
| async requestOrgJoin(organisation: Organisation, userId: number): Promise<User> { | ||
| const user = await User.findOne({ | ||
| where: { id: userId }, | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,27 @@ | ||
| /* istanbul ignore file */ | ||
| import { ApiProperty } from "@nestjs/swagger"; | ||
| import { JsonApiDto } from "../decorators"; | ||
| import { OrganisationInvite } from "@terramatch-microservices/database/entities"; | ||
| import { populateDto } from "./json-api-attributes"; | ||
|
|
||
| @JsonApiDto({ type: "organisationInvites" }) | ||
| export class OrganisationInviteDto { | ||
| constructor(invite: OrganisationInvite) { | ||
| populateDto<OrganisationInviteDto, OrganisationInvite>(this, invite, {}); | ||
| } | ||
|
|
||
| @ApiProperty({ description: "Primary key of the organisation invite." }) | ||
| id: number; | ||
|
|
||
| @ApiProperty({ description: "UUID of the organisation invite." }) | ||
| uuid: string; | ||
|
|
||
| @ApiProperty({ description: "ID of the organisation this invite belongs to." }) | ||
| organisationId: number; | ||
|
|
||
| @ApiProperty({ description: "Email address this invite was sent to." }) | ||
| emailAddress: string; | ||
|
|
||
| @ApiProperty({ description: "Timestamp when the invite was accepted.", nullable: true, type: String }) | ||
| acceptedAt: Date | null; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,57 @@ | ||
| import { Dictionary } from "lodash"; | ||
| import { TMLogger } from "../util/tm-logger"; | ||
| import { EmailSender } from "./email-sender"; | ||
| import { EmailService } from "./email.service"; | ||
| import { Organisation } from "@terramatch-microservices/database/entities"; | ||
|
|
||
| const DEFAULT_SIGNUP_BASE = "/auth/signup"; | ||
|
|
||
| type OrganisationInviteEmailData = { | ||
| organisationId: number; | ||
| emailAddress: string; | ||
| token: string; | ||
| callbackUrl?: string | null; | ||
| }; | ||
|
|
||
| const EMAIL_ORGANISATION_INVITE_KEYS = { | ||
| body: "v2-organisation-invite-received-create.body", | ||
| subjectKey: "v2-organisation-invite-received-create.subject", | ||
| titleKey: "v2-organisation-invite-received-create.title", | ||
| ctaKey: "v2-organisation-invite-received-create.cta" | ||
| } as const; | ||
|
|
||
| export class OrganisationInviteEmail extends EmailSender<OrganisationInviteEmailData> { | ||
| static readonly NAME = "organisationInvite"; | ||
|
|
||
| private readonly logger = new TMLogger(OrganisationInviteEmail.name); | ||
|
|
||
| constructor(data: OrganisationInviteEmailData) { | ||
| super(OrganisationInviteEmail.NAME, data); | ||
| } | ||
|
|
||
| async send(emailService: EmailService) { | ||
| const organisation = await Organisation.findOne({ | ||
| where: { id: this.data.organisationId }, | ||
| attributes: ["id", "name"] | ||
| }); | ||
| if (organisation == null) { | ||
| this.logger.error(`Organisation not found [${this.data.organisationId}]`); | ||
| return; | ||
| } | ||
|
|
||
| const base = this.data.callbackUrl ?? DEFAULT_SIGNUP_BASE; | ||
| const link = `${base.replace(/\/$/, "")}/${this.data.token}`; | ||
|
|
||
| const i18nReplacements: Dictionary<string> = { | ||
| "{organisationName}": organisation.name ?? "" | ||
| }; | ||
| const additionalValues = { | ||
| link, | ||
| transactional: "transactional" | ||
| }; | ||
| await emailService.sendI18nTemplateEmail(this.data.emailAddress, "en-US", EMAIL_ORGANISATION_INVITE_KEYS, { | ||
| i18nReplacements, | ||
| additionalValues | ||
| }); | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This should be enforced by the DTO - a new DTO should be created that only accepts orgs on this endpoint.