Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Expand Up @@ -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";
Expand All @@ -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 {
Expand Down Expand Up @@ -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");
}
Copy link
Collaborator

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.


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;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎨 you can streamline this a little and just return the result of document.addData()

}

@Delete(":uuid")
@ApiOperation({
operationId: "deleteUserAssociation",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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);
});
});
});
63 changes: 61 additions & 2 deletions apps/user-service/src/user-association/user-association.service.ts
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,
Expand All @@ -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";
Expand All @@ -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";
Expand Down Expand Up @@ -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");
Copy link
Collaborator

Choose a reason for hiding this comment

The 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 },
Expand Down
1 change: 1 addition & 0 deletions libs/common/src/lib/dto/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ export { OrganisationLightDto, OrganisationFullDto } from "./organisation.dto";
export { DelayedJobDto } from "./delayed-job.dto";
export { ActionDto } from "./action.dto";
export { FinancialReportLightDto } from "./financial-report.dto";
export { OrganisationInviteDto } from "./organisation-invite.dto";
27 changes: 27 additions & 0 deletions libs/common/src/lib/dto/organisation-invite.dto.ts
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;
}
4 changes: 3 additions & 1 deletion libs/common/src/lib/email/email.processor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { ProjectMonitoringNotificationEmail } from "./project-monitoring-notific
import { OrganisationApprovedEmail } from "./organisation-approved.email";
import { OrganisationRejectedEmail } from "./organisation-rejected.email";
import { OrganisationJoinRequestEmail } from "./organisation-join-request.email";
import { OrganisationInviteEmail } from "./organisation-invite.email";

export type SpecificEntityData = {
type: EntityType;
Expand All @@ -42,7 +43,8 @@ const EMAIL_PROCESSORS: ((new (data: unknown) => EmailSender<unknown>) & { NAME:
ProjectMonitoringNotificationEmail,
OrganisationApprovedEmail,
OrganisationRejectedEmail,
OrganisationJoinRequestEmail
OrganisationJoinRequestEmail,
OrganisationInviteEmail
];

/**
Expand Down
57 changes: 57 additions & 0 deletions libs/common/src/lib/email/organisation-invite.email.ts
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
});
}
}
1 change: 1 addition & 0 deletions libs/database/src/lib/entities/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ export * from "./notification.entity";
export * from "./nursery.entity";
export * from "./nursery-report.entity";
export * from "./organisation.entity";
export * from "./organisation-invite.entity";
export * from "./organisation-user.entity";
export * from "./ownership-stake.entity";
export * from "./permission.entity";
Expand Down
Loading