From 2b096aed53e059ce2f5927f2477a4d93047067f6 Mon Sep 17 00:00:00 2001 From: cesarLima1 Date: Fri, 27 Feb 2026 17:13:55 -0400 Subject: [PATCH 1/2] [TM-3027] start up endpoint to send invitation to join organization using mail --- .../dto/organisation-invite-request.dto.ts | 22 +++++++ .../user-association.controller.ts | 42 ++++++++++++- .../user-association.service.spec.ts | 62 +++++++++++++++++- .../user-association.service.ts | 63 ++++++++++++++++++- libs/common/src/lib/dto/index.ts | 1 + .../src/lib/dto/organisation-invite.dto.ts | 27 ++++++++ libs/common/src/lib/email/email.processor.ts | 4 +- .../lib/email/organisation-invite.email.ts | 57 +++++++++++++++++ libs/database/src/lib/entities/index.ts | 1 + .../entities/organisation-invite.entity.ts | 47 ++++++++++++++ 10 files changed, 320 insertions(+), 6 deletions(-) create mode 100644 apps/user-service/src/user-association/dto/organisation-invite-request.dto.ts create mode 100644 libs/common/src/lib/dto/organisation-invite.dto.ts create mode 100644 libs/common/src/lib/email/organisation-invite.email.ts create mode 100644 libs/database/src/lib/entities/organisation-invite.entity.ts diff --git a/apps/user-service/src/user-association/dto/organisation-invite-request.dto.ts b/apps/user-service/src/user-association/dto/organisation-invite-request.dto.ts new file mode 100644 index 000000000..a157612c6 --- /dev/null +++ b/apps/user-service/src/user-association/dto/organisation-invite-request.dto.ts @@ -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; +} diff --git a/apps/user-service/src/user-association/user-association.controller.ts b/apps/user-service/src/user-association/user-association.controller.ts index 1caa0f892..a27042c81 100644 --- a/apps/user-service/src/user-association/user-association.controller.ts +++ b/apps/user-service/src/user-association/user-association.controller.ts @@ -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; + } + @Delete(":uuid") @ApiOperation({ operationId: "deleteUserAssociation", diff --git a/apps/user-service/src/user-association/user-association.service.spec.ts b/apps/user-service/src/user-association/user-association.service.spec.ts index e3de0e032..734e256de 100644 --- a/apps/user-service/src/user-association/user-association.service.spec.ts +++ b/apps/user-service/src/user-association/user-association.service.spec.ts @@ -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: "exists@example.com" }); + + jest.spyOn(User, "findOne").mockResolvedValue(user); + + await expect(service.inviteOrganisationUser(org, "exists@example.com")).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: "new@example.com" }); + const invite = { + id: 1, + uuid: "invite-uuid", + organisationId: org.id, + emailAddress: "new@example.com", + 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, "new@example.com", "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: "new@example.com", + token: "fake-token" + }) + ); + expect(emailQueue.add).toHaveBeenCalled(); + expect(result).toBe(invite); + }); + }); }); diff --git a/apps/user-service/src/user-association/user-association.service.ts b/apps/user-service/src/user-association/user-association.service.ts index 8b69fbffb..fdda66123 100644 --- a/apps/user-service/src/user-association/user-association.service.ts +++ b/apps/user-service/src/user-association/user-association.service.ts @@ -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 { + const existingUser = await User.findOne({ + where: { emailAddress }, + attributes: ["id"] + }); + if (existingUser != null) { + throw new UnprocessableEntityException("User is already a part of this programme"); + } + + 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 { const user = await User.findOne({ where: { id: userId }, diff --git a/libs/common/src/lib/dto/index.ts b/libs/common/src/lib/dto/index.ts index 05eed4098..e6b5fbf7c 100644 --- a/libs/common/src/lib/dto/index.ts +++ b/libs/common/src/lib/dto/index.ts @@ -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"; diff --git a/libs/common/src/lib/dto/organisation-invite.dto.ts b/libs/common/src/lib/dto/organisation-invite.dto.ts new file mode 100644 index 000000000..dc971be37 --- /dev/null +++ b/libs/common/src/lib/dto/organisation-invite.dto.ts @@ -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(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; +} diff --git a/libs/common/src/lib/email/email.processor.ts b/libs/common/src/lib/email/email.processor.ts index d817e8a91..cce95a084 100644 --- a/libs/common/src/lib/email/email.processor.ts +++ b/libs/common/src/lib/email/email.processor.ts @@ -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; @@ -42,7 +43,8 @@ const EMAIL_PROCESSORS: ((new (data: unknown) => EmailSender) & { NAME: ProjectMonitoringNotificationEmail, OrganisationApprovedEmail, OrganisationRejectedEmail, - OrganisationJoinRequestEmail + OrganisationJoinRequestEmail, + OrganisationInviteEmail ]; /** diff --git a/libs/common/src/lib/email/organisation-invite.email.ts b/libs/common/src/lib/email/organisation-invite.email.ts new file mode 100644 index 000000000..f6c050739 --- /dev/null +++ b/libs/common/src/lib/email/organisation-invite.email.ts @@ -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 { + 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 = { + "{organisationName}": organisation.name ?? "" + }; + const additionalValues = { + link, + transactional: "transactional" + }; + await emailService.sendI18nTemplateEmail(this.data.emailAddress, "en-US", EMAIL_ORGANISATION_INVITE_KEYS, { + i18nReplacements, + additionalValues + }); + } +} diff --git a/libs/database/src/lib/entities/index.ts b/libs/database/src/lib/entities/index.ts index a53bee0a5..8ba16566e 100644 --- a/libs/database/src/lib/entities/index.ts +++ b/libs/database/src/lib/entities/index.ts @@ -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"; diff --git a/libs/database/src/lib/entities/organisation-invite.entity.ts b/libs/database/src/lib/entities/organisation-invite.entity.ts new file mode 100644 index 000000000..73d49681d --- /dev/null +++ b/libs/database/src/lib/entities/organisation-invite.entity.ts @@ -0,0 +1,47 @@ +import { + AllowNull, + AutoIncrement, + BelongsTo, + Column, + ForeignKey, + Index, + Model, + PrimaryKey, + Table +} from "sequelize-typescript"; +import { BIGINT, DATE, STRING, UUID, UUIDV4 } from "sequelize"; +import { Organisation } from "./organisation.entity"; +import { User } from "./user.entity"; + +@Table({ tableName: "v2_organisation_invites", underscored: true }) +export class OrganisationInvite extends Model { + @PrimaryKey + @AutoIncrement + @Column(BIGINT.UNSIGNED) + override id: number; + + @Index + @Column({ type: UUID, defaultValue: UUIDV4 }) + uuid: string; + + @ForeignKey(() => Organisation) + @Column(BIGINT.UNSIGNED) + organisationId: number; + + @Column(STRING) + emailAddress: string; + + @AllowNull + @Column(STRING) + token: string | null; + + @AllowNull + @Column(DATE) + acceptedAt: Date | null; + + @BelongsTo(() => Organisation) + organisation: Organisation | null; + + @BelongsTo(() => User, { foreignKey: "emailAddress", targetKey: "emailAddress" }) + user: User | null; +} From 1b35891c939b58e88eba7341dcc1d5e7ee55f921 Mon Sep 17 00:00:00 2001 From: cesarLima1 Date: Mon, 2 Mar 2026 12:06:23 -0400 Subject: [PATCH 2/2] [TM-3027] code improvements --- .../dto/organisation-invite-param.dto.ts | 12 ++++++++ .../user-association.controller.ts | 13 +++------ .../user-association.service.spec.ts | 28 +++++++++---------- .../user-association.service.ts | 7 +++-- .../entities/organisation-invite.entity.ts | 20 ++++++++++--- 5 files changed, 51 insertions(+), 29 deletions(-) create mode 100644 apps/user-service/src/user-association/dto/organisation-invite-param.dto.ts diff --git a/apps/user-service/src/user-association/dto/organisation-invite-param.dto.ts b/apps/user-service/src/user-association/dto/organisation-invite-param.dto.ts new file mode 100644 index 000000000..6958d9857 --- /dev/null +++ b/apps/user-service/src/user-association/dto/organisation-invite-param.dto.ts @@ -0,0 +1,12 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { IsIn } from "class-validator"; +import { SingleResourceDto } from "@terramatch-microservices/common/dto/single-resource.dto"; + +export class OrganisationInviteParamDto extends SingleResourceDto { + @IsIn(["organisations"]) + @ApiProperty({ + description: "The model type (organisations only for invite)", + enum: ["organisations"] + }) + model: "organisations"; +} diff --git a/apps/user-service/src/user-association/user-association.controller.ts b/apps/user-service/src/user-association/user-association.controller.ts index a27042c81..26e88b714 100644 --- a/apps/user-service/src/user-association/user-association.controller.ts +++ b/apps/user-service/src/user-association/user-association.controller.ts @@ -22,6 +22,7 @@ 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 { OrganisationInviteParamDto } from "./dto/organisation-invite-param.dto"; import { OrganisationInviteDto } from "@terramatch-microservices/common/dto"; @Controller("userAssociations/v3/:model") @@ -88,14 +89,10 @@ export class UserAssociationController { description: "A user with this email already exists." }) async inviteOrganisationUser( - @Param() { model, uuid }: UserAssociationModelParamDto, + @Param() { uuid }: OrganisationInviteParamDto, @Body() body: OrganisationInviteRequestDto ) { - if (model !== "organisations") { - throw new BadRequestException("Invites are only supported for organisations"); - } - - const processor = this.userAssociationService.createProcessor(model, uuid); + const processor = this.userAssociationService.createProcessor("organisations", uuid); const organisation = await processor.getEntity(); await this.policyService.authorize("update", organisation); @@ -105,9 +102,7 @@ export class UserAssociationController { body.callbackUrl ); - const document = buildJsonApi(OrganisationInviteDto); - document.addData(invite.uuid, new OrganisationInviteDto(invite)); - return document; + return buildJsonApi(OrganisationInviteDto).addData(invite.uuid, new OrganisationInviteDto(invite)).document; } @Delete(":uuid") diff --git a/apps/user-service/src/user-association/user-association.service.spec.ts b/apps/user-service/src/user-association/user-association.service.spec.ts index 734e256de..503c270e8 100644 --- a/apps/user-service/src/user-association/user-association.service.spec.ts +++ b/apps/user-service/src/user-association/user-association.service.spec.ts @@ -23,7 +23,8 @@ import { Project, Role, ModelHasRole, - OrganisationInvite + OrganisationInvite, + PasswordReset } from "@terramatch-microservices/database/entities"; import { NotFoundException, @@ -417,16 +418,16 @@ describe("UserAssociationService", () => { }); it("should call handleUserNotFound when user does not exist", async () => { - const project = await ProjectFactory.create(); - const org = await OrganisationFactory.create(); - project.organisationId = org.id; - const newUser = await UserFactory.create({ organisationId: org.id }); - const role = await RoleFactory.create({ name: "project-developer" }); + const project = { id: 1, uuid: "project-uuid", organisationId: 1 } as Project; + const org = { id: 1, uuid: "org-uuid", name: "Test Org" } as Organisation; + const newUser = { id: 10, emailAddress: "new@example.com", organisationId: org.id } as User; + const role = { id: 1, name: "project-developer" } as Role; 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); + jest.spyOn(PasswordReset, "create").mockResolvedValue({} as PasswordReset); jest.spyOn(Organisation, "findOne").mockResolvedValue(org); jest.spyOn(ProjectInvite, "create").mockResolvedValue({} as ProjectInvite); jwtService.signAsync.mockResolvedValue("fake-token"); @@ -835,8 +836,8 @@ describe("UserAssociationService", () => { describe("inviteOrganisationUser", () => { it("should throw UnprocessableEntityException when user with email already exists", async () => { - const org = await OrganisationFactory.create(); - const user = await UserFactory.create({ emailAddress: "exists@example.com" }); + const org = { id: 1, uuid: "org-uuid", name: "Test Org" } as Organisation; + const user = { id: 1, emailAddress: "exists@example.com" } as User; jest.spyOn(User, "findOne").mockResolvedValue(user); @@ -846,9 +847,9 @@ describe("UserAssociationService", () => { }); 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: "new@example.com" }); + const org = { id: 1, uuid: "org-uuid", name: "Test Org" } as Organisation; + const role = { id: 1, name: "project-developer" } as Role; + const newUser = { id: 10, emailAddress: "new@example.com", organisationId: org.id } as User; const invite = { id: 1, uuid: "invite-uuid", @@ -863,7 +864,7 @@ describe("UserAssociationService", () => { 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(PasswordReset, "create").mockResolvedValue({} as PasswordReset); jest.spyOn(OrganisationInvite, "create").mockResolvedValue(invite); emailQueue.add = jest.fn().mockResolvedValue({} as Job); @@ -872,12 +873,11 @@ describe("UserAssociationService", () => { 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: "new@example.com", - token: "fake-token" + token: expect.any(String) }) ); expect(emailQueue.add).toHaveBeenCalled(); diff --git a/apps/user-service/src/user-association/user-association.service.ts b/apps/user-service/src/user-association/user-association.service.ts index 2e0dd993d..7ce7f9833 100644 --- a/apps/user-service/src/user-association/user-association.service.ts +++ b/apps/user-service/src/user-association/user-association.service.ts @@ -324,7 +324,7 @@ export class UserAssociationService { attributes: ["id"] }); if (existingUser != null) { - throw new UnprocessableEntityException("User is already a part of this programme"); + throw new UnprocessableEntityException("A user with this email address already exists"); } const newUser = await User.create({ @@ -347,7 +347,10 @@ export class UserAssociationService { emailAddress, token } as OrganisationInvite); - + await PasswordReset.create({ + userId: newUser.id, + token + } as PasswordReset); try { await new OrganisationInviteEmail({ organisationId: organisation.id, diff --git a/libs/database/src/lib/entities/organisation-invite.entity.ts b/libs/database/src/lib/entities/organisation-invite.entity.ts index 73d49681d..d6d1958e6 100644 --- a/libs/database/src/lib/entities/organisation-invite.entity.ts +++ b/libs/database/src/lib/entities/organisation-invite.entity.ts @@ -9,20 +9,32 @@ import { PrimaryKey, Table } from "sequelize-typescript"; -import { BIGINT, DATE, STRING, UUID, UUIDV4 } from "sequelize"; +import { + BIGINT, + CreationOptional, + DATE, + InferAttributes, + InferCreationAttributes, + STRING, + UUID, + UUIDV4 +} from "sequelize"; import { Organisation } from "./organisation.entity"; import { User } from "./user.entity"; @Table({ tableName: "v2_organisation_invites", underscored: true }) -export class OrganisationInvite extends Model { +export class OrganisationInvite extends Model< + InferAttributes, + InferCreationAttributes +> { @PrimaryKey @AutoIncrement @Column(BIGINT.UNSIGNED) - override id: number; + override id: CreationOptional; @Index @Column({ type: UUID, defaultValue: UUIDV4 }) - uuid: string; + uuid: CreationOptional; @ForeignKey(() => Organisation) @Column(BIGINT.UNSIGNED)