diff --git a/api/src/team/application/organization-invitations/organization-invitation.controller.js b/api/src/team/application/organization-invitations/organization-invitation.controller.js index e2d48707c11..47cf6e11cf9 100644 --- a/api/src/team/application/organization-invitations/organization-invitation.controller.js +++ b/api/src/team/application/organization-invitations/organization-invitation.controller.js @@ -109,12 +109,10 @@ const sendInvitations = async function (request, h) { const resendInvitation = async function (request, h) { const organizationId = request.params.id; const email = request.payload.data.attributes.email; - const locale = extractLocaleFromRequest(request); const organizationInvitation = await usecases.resendOrganizationInvitation({ organizationId, email, - locale, }); return h.response(organizationInvitationSerializer.serialize(organizationInvitation)); }; diff --git a/api/src/team/domain/services/organization-invitation.service.js b/api/src/team/domain/services/organization-invitation.service.js index 54a65f02bb1..ebeba903055 100644 --- a/api/src/team/domain/services/organization-invitation.service.js +++ b/api/src/team/domain/services/organization-invitation.service.js @@ -44,6 +44,12 @@ const createOrUpdateOrganizationInvitation = async ({ role, locale, }); + } else { + organizationInvitation = await organizationInvitationRepository.update({ + id: organizationInvitation.id, + ...(role && { role }), + ...(locale && { locale }), + }); } const organization = await organizationRepository.get(organizationId); @@ -67,8 +73,7 @@ const createOrUpdateOrganizationInvitation = async ({ throw new SendingEmailError(email); } - - return await organizationInvitationRepository.updateModificationDate(organizationInvitation.id); + return organizationInvitation; }; /** diff --git a/api/src/team/domain/usecases/resend-organization-invitation.usecase.js b/api/src/team/domain/usecases/resend-organization-invitation.usecase.js index 0fb555b05cb..61cd4fae253 100644 --- a/api/src/team/domain/usecases/resend-organization-invitation.usecase.js +++ b/api/src/team/domain/usecases/resend-organization-invitation.usecase.js @@ -1,17 +1,15 @@ const resendOrganizationInvitation = async function ({ - organizationId, email, - locale, + organizationId, organizationRepository, organizationInvitationRepository, organizationInvitationService, }) { return organizationInvitationService.createOrUpdateOrganizationInvitation({ + email, + organizationId, organizationRepository, organizationInvitationRepository, - organizationId, - email, - locale, }); }; diff --git a/api/src/team/infrastructure/repositories/organization-invitation.repository.js b/api/src/team/infrastructure/repositories/organization-invitation.repository.js index bb0e48d10dd..3a6a5cd726e 100644 --- a/api/src/team/infrastructure/repositories/organization-invitation.repository.js +++ b/api/src/team/infrastructure/repositories/organization-invitation.repository.js @@ -126,6 +126,25 @@ const updateModificationDate = async function (id) { return new OrganizationInvitation(organizationInvitation); }; +/** + * @param organizationInvitation + * @returns {Promise} + */ +const update = async function (organizationInvitation) { + const [updatedOrganizationInvitation] = await knex('organization-invitations') + .where({ id: organizationInvitation.id }) + .update({ + ...organizationInvitation, + updatedAt: new Date(), + }) + .returning('*'); + + if (!updatedOrganizationInvitation) { + throw new NotFoundError(`Organization invitation of id ${organizationInvitation.id} is not found.`); + } + return new OrganizationInvitation(updatedOrganizationInvitation); +}; + export const organizationInvitationRepository = { create, findOnePendingByOrganizationIdAndEmail, @@ -135,4 +154,5 @@ export const organizationInvitationRepository = { markAsAccepted, markAsCancelled, updateModificationDate, + update, }; diff --git a/api/tests/team/integration/domain/services/organization-invitation.service.test.js b/api/tests/team/integration/domain/services/organization-invitation.service.test.js index ae256bddc23..c26a9c80a75 100644 --- a/api/tests/team/integration/domain/services/organization-invitation.service.test.js +++ b/api/tests/team/integration/domain/services/organization-invitation.service.test.js @@ -14,7 +14,7 @@ import { organizationInvitationService } from '../../../../../src/team/domain/se import { organizationInvitationRepository } from '../../../../../src/team/infrastructure/repositories/organization-invitation.repository.js'; import { catchErr, databaseBuilder, expect, sinon } from '../../../../test-helper.js'; -describe('Integration | Team | Domain | Service | organization-invitation', function () { +describe('Integration | Team | Domain | Service | organizationInvitationService', function () { describe('#createOrUpdateOrganizationInvitation', function () { let clock; const now = new Date('2021-01-02'); @@ -60,27 +60,40 @@ describe('Integration | Team | Domain | Service | organization-invitation', func ); }); - it('re-sends an email with same code when organization invitation already exists with status pending', async function () { - // given - const organizationInvitation = databaseBuilder.factory.buildOrganizationInvitation({ - status: OrganizationInvitation.StatusType.PENDING, - }); - await databaseBuilder.commit(); + context('when the organizationInvitation already exists with pending status', function () { + it('updates the organizationInvitation and re-sends an email with same code', async function () { + // given + const locale = 'fr'; + const role = Membership.roles.MEMBER; + const organizationInvitation = databaseBuilder.factory.buildOrganizationInvitation({ + status: OrganizationInvitation.StatusType.PENDING, + role, + locale, + }); + await databaseBuilder.commit(); - // when - const result = await organizationInvitationService.createOrUpdateOrganizationInvitation({ - organizationId: organizationInvitation.organizationId, - email: organizationInvitation.email, - organizationRepository, - organizationInvitationRepository, - }); + const newRole = Membership.roles.ADMIN; + const newLocale = 'en'; - // then - const expectedOrganizationInvitation = { - ...organizationInvitation, - updatedAt: now, - }; - expect(_.omit(result, 'organizationName')).to.deep.equal(expectedOrganizationInvitation); + // when + const result = await organizationInvitationService.createOrUpdateOrganizationInvitation({ + organizationId: organizationInvitation.organizationId, + email: organizationInvitation.email, + role: newRole, + locale: newLocale, + organizationRepository, + organizationInvitationRepository, + }); + + // then + const expectedOrganizationInvitation = { + ...organizationInvitation, + role: newRole, + locale: newLocale, + updatedAt: now, + }; + expect(_.omit(result, 'organizationName')).to.deep.equal(expectedOrganizationInvitation); + }); }); context('when recipient email has an invalid domain', function () { diff --git a/api/tests/team/integration/domain/usecases/create-organization-invitation-by-admin.usecase.test.js b/api/tests/team/integration/domain/usecases/create-organization-invitation-by-admin.usecase.test.js new file mode 100644 index 00000000000..cdc9ab1c259 --- /dev/null +++ b/api/tests/team/integration/domain/usecases/create-organization-invitation-by-admin.usecase.test.js @@ -0,0 +1,54 @@ +import { Membership } from '../../../../../src/shared/domain/models/index.js'; +import { OrganizationArchivedError } from '../../../../../src/team/domain/errors.js'; +import { usecases } from '../../../../../src/team/domain/usecases/index.js'; +import { catchErr, databaseBuilder, expect, knex } from '../../../../test-helper.js'; + +describe('Integration | Team | Domain | UseCase | create-organization-invitation-by-admin', function () { + it('creates an organizationInvitation', async function () { + // given + const organizationId = databaseBuilder.factory.buildOrganization().id; + await databaseBuilder.commit(); + + const email = 'member@organization.org'; + const locale = 'fr-fr'; + const role = Membership.roles.MEMBER; + + // when + await usecases.createOrganizationInvitationByAdmin({ + organizationId, + email, + locale, + role, + }); + + // then + const organizationInvitations = await knex('organization-invitations'); + expect(organizationInvitations).to.have.lengthOf(1); + const organizationInvitation = organizationInvitations[0]; + expect(organizationInvitation).to.deep.include({ email, locale, role }); + }); + + context('when the organization is archived', function () { + it('throws an OrganizationArchivedError', async function () { + // given + const archivedBy = databaseBuilder.factory.buildUser().id; + const organizationId = databaseBuilder.factory.buildOrganization({ archivedAt: '2022-02-02', archivedBy }).id; + await databaseBuilder.commit(); + + const email = 'member@organization.org'; + const locale = 'fr-fr'; + const role = Membership.roles.MEMBER; + + // when + const error = await catchErr(usecases.createOrganizationInvitationByAdmin)({ + organizationId, + email, + locale, + role, + }); + + // then + expect(error).to.be.instanceOf(OrganizationArchivedError); + }); + }); +}); diff --git a/api/tests/team/integration/domain/usecases/create-organization-invitations.usecase.test.js b/api/tests/team/integration/domain/usecases/create-organization-invitations.usecase.test.js new file mode 100644 index 00000000000..51f239600b8 --- /dev/null +++ b/api/tests/team/integration/domain/usecases/create-organization-invitations.usecase.test.js @@ -0,0 +1,57 @@ +import { OrganizationArchivedError } from '../../../../../src/team/domain/errors.js'; +import { usecases } from '../../../../../src/team/domain/usecases/index.js'; +import { catchErr, databaseBuilder, expect, knex } from '../../../../test-helper.js'; + +describe('Integration | Team | Domain | UseCase | create-organization-invitations', function () { + it('creates multiple organizationInvitations with trimmed and deduplicated emails', async function () { + // given + const organizationId = databaseBuilder.factory.buildOrganization().id; + await databaseBuilder.commit(); + + const emails = ['member01@organization.org', ' member01@organization.org', 'member02@organization.org']; + const locale = 'fr-fr'; + + // when + await usecases.createOrganizationInvitations({ + organizationId, + emails, + locale, + }); + + // then + const organizationInvitations = await knex('organization-invitations'); + expect(organizationInvitations).to.have.lengthOf(2); + const organizationInvitationMap = Object.fromEntries( + organizationInvitations.map((organizationInvitation) => { + return [organizationInvitation.email, organizationInvitation]; + }), + ); + const organizationInvitation1 = organizationInvitationMap['member01@organization.org']; + expect(organizationInvitation1).to.deep.include({ locale, role: null }); + + const organizationInvitation2 = organizationInvitationMap['member02@organization.org']; + expect(organizationInvitation2).to.deep.include({ locale, role: null }); + }); + + context('when the organization is archived', function () { + it('throws an OrganizationArchivedError', async function () { + // given + const archivedBy = databaseBuilder.factory.buildUser().id; + const organizationId = databaseBuilder.factory.buildOrganization({ archivedAt: '2022-02-02', archivedBy }).id; + await databaseBuilder.commit(); + + const emails = ['member@organization.org']; + const locale = 'fr-fr'; + + // when + const error = await catchErr(usecases.createOrganizationInvitations)({ + organizationId, + emails, + locale, + }); + + // then + expect(error).to.be.instanceOf(OrganizationArchivedError); + }); + }); +}); diff --git a/api/tests/team/integration/infrastructure/repositories/organization-invitation.repository.test.js b/api/tests/team/integration/infrastructure/repositories/organization-invitation.repository.test.js index 65eb0573332..8e9a13c23ae 100644 --- a/api/tests/team/integration/infrastructure/repositories/organization-invitation.repository.test.js +++ b/api/tests/team/integration/infrastructure/repositories/organization-invitation.repository.test.js @@ -353,4 +353,45 @@ describe('Integration | Team | Infrastructure | Repository | organization-invita expect(error).to.be.instanceOf(NotFoundError); }); }); + + describe('#update', function () { + it('updates information in organization invitation', async function () { + // given + const organizationInvitation = databaseBuilder.factory.buildOrganizationInvitation({ + locale: 'en', + role: Membership.roles.MEMBER, + }); + await databaseBuilder.commit(); + + const updatedLocale = 'fr'; + const updatedRole = Membership.roles.ADMIN; + + // when + const updatedOrganizationInvitation = await organizationInvitationRepository.update({ + ...organizationInvitation, + locale: updatedLocale, + role: updatedRole, + }); + + // then + expect(updatedOrganizationInvitation).to.be.instanceOf(OrganizationInvitation); + expect(updatedOrganizationInvitation.id).to.equal(organizationInvitation.id); + expect(updatedOrganizationInvitation.locale).to.equal(updatedLocale); + expect(updatedOrganizationInvitation.role).to.equal(updatedRole); + expect(updatedOrganizationInvitation.updatedAt).to.deep.equal(now); + }); + + it('throws an error if organization invitation is not found', async function () { + // when + const error = await catchErr(organizationInvitationRepository.update)({ + id: 1234, + locale: 'fr', + role: Membership.roles.ADMIN, + }); + + // then + expect(error).to.be.instanceOf(NotFoundError); + expect(error.message).to.equal('Organization invitation of id 1234 is not found.'); + }); + }); }); diff --git a/api/tests/team/unit/domain/services/organization-invitation.service.test.js b/api/tests/team/unit/domain/services/organization-invitation.service.test.js index 0e6aa511012..925024ca88f 100644 --- a/api/tests/team/unit/domain/services/organization-invitation.service.test.js +++ b/api/tests/team/unit/domain/services/organization-invitation.service.test.js @@ -19,6 +19,7 @@ describe('Unit | Team | Domain | Service | organization-invitation', function () organizationInvitationRepository = { create: sinon.stub(), findOnePendingByOrganizationIdAndEmail: sinon.stub(), + update: sinon.stub(), updateModificationDate: sinon.stub(), }; organizationRepository = { @@ -32,53 +33,6 @@ describe('Unit | Team | Domain | Service | organization-invitation', function () describe('#createOrUpdateOrganizationInvitation', function () { context('when organization-invitation does not exist', function () { - it('should create a new organization-invitation and send an email with organizationId, email, code and locale', async function () { - // given - const role = null; - const tags = undefined; - const locale = 'fr-fr'; - const organization = domainBuilder.buildOrganization(); - const organizationInvitation = new OrganizationInvitation({ - role: Membership.roles.MEMBER, - status: 'pending', - code, - }); - - organizationInvitationRepository.findOnePendingByOrganizationIdAndEmail - .withArgs({ organizationId: organization.id, email: userEmailAddress }) - .resolves(null); - organizationInvitationRepository.create.resolves(organizationInvitation); - organizationRepository.get.resolves(organization); - - // when - await organizationInvitationService.createOrUpdateOrganizationInvitation({ - organizationRepository, - organizationInvitationRepository, - organizationId: organization.id, - email: userEmailAddress, - locale, - role, - dependencies: { mailService }, - }); - - // then - expect(organizationInvitationRepository.create).to.has.been.calledWithExactly({ - organizationId: organization.id, - email: userEmailAddress, - code: sinon.match.string, - role, - locale, - }); - expect(mailService.sendOrganizationInvitationEmail).to.has.been.calledWithExactly({ - email: userEmailAddress, - organizationName: organization.name, - organizationInvitationId: organizationInvitation.id, - code, - locale, - tags, - }); - }); - context('when recipient email has an invalid domain', function () { it('should throw an error', async function () { // given @@ -127,12 +81,14 @@ describe('Unit | Team | Domain | Service | organization-invitation', function () const locale = 'fr-fr'; const organization = domainBuilder.buildOrganization(); const organizationInvitation = new OrganizationInvitation({ + id: 123456, role: Membership.roles.MEMBER, status: 'pending', code, }); organizationInvitationRepository.findOnePendingByOrganizationIdAndEmail.resolves(organizationInvitation); + organizationInvitationRepository.update.resolves({ id: 123456 }); organizationRepository.get.resolves(organization); // when @@ -150,42 +106,13 @@ describe('Unit | Team | Domain | Service | organization-invitation', function () email: userEmailAddress, organizationName: organization.name, organizationInvitationId: organizationInvitation.id, - code, + code: undefined, locale, tags, }; expect(mailService.sendOrganizationInvitationEmail).to.has.been.calledWithExactly(expectedParameters); }); - - it('should update organization-invitation modification date', async function () { - // given - const locale = 'fr-fr'; - const organization = domainBuilder.buildOrganization(); - const organizationInvitation = new OrganizationInvitation({ - role: Membership.roles.MEMBER, - status: 'pending', - code, - }); - - organizationInvitationRepository.findOnePendingByOrganizationIdAndEmail.resolves(organizationInvitation); - organizationRepository.get.resolves(organization); - - // when - await organizationInvitationService.createOrUpdateOrganizationInvitation({ - organizationRepository, - organizationInvitationRepository, - organizationId: organization.id, - email: userEmailAddress, - locale, - dependencies: { mailService }, - }); - - // then - expect(organizationInvitationRepository.updateModificationDate).to.have.been.calledWithExactly( - organizationInvitation.id, - ); - }); }); context('when mailing provider returns an invalid email error', function () { diff --git a/api/tests/team/unit/domain/usecases/create-organization-invitation-by-admin.usecase.test.js b/api/tests/team/unit/domain/usecases/create-organization-invitation-by-admin.usecase.test.js deleted file mode 100644 index 352b2dd4a90..00000000000 --- a/api/tests/team/unit/domain/usecases/create-organization-invitation-by-admin.usecase.test.js +++ /dev/null @@ -1,72 +0,0 @@ -import { Membership } from '../../../../../src/shared/domain/models/index.js'; -import { OrganizationArchivedError } from '../../../../../src/team/domain/errors.js'; -import { createOrganizationInvitationByAdmin } from '../../../../../src/team/domain/usecases/create-organization-invitation-by-admin.usecase.js'; -import { catchErr, domainBuilder, expect, sinon } from '../../../../test-helper.js'; - -describe('Unit | UseCase | create-organization-invitation-by-admin', function () { - describe('#createOrganizationInvitationByAdmin', function () { - it('should create one organization-invitation with organizationId, role and email', async function () { - // given - const organization = domainBuilder.buildOrganization(); - const email = 'member@organization.org'; - const locale = 'fr-fr'; - const role = Membership.roles.MEMBER; - - const organizationInvitationRepository = sinon.stub(); - const organizationRepository = { get: sinon.stub().resolves(organization) }; - const organizationInvitationService = { createOrUpdateOrganizationInvitation: sinon.stub() }; - - // when - await createOrganizationInvitationByAdmin({ - organizationId: organization.id, - email, - locale, - role, - organizationRepository, - organizationInvitationRepository, - organizationInvitationService, - }); - - // then - expect(organizationInvitationService.createOrUpdateOrganizationInvitation).to.has.been.calledOnce; - expect(organizationInvitationService.createOrUpdateOrganizationInvitation).to.has.been.calledWithExactly({ - organizationId: organization.id, - email, - locale, - role, - organizationRepository, - organizationInvitationRepository, - }); - }); - - it('should throw an organization archived error when it is archived', async function () { - // given - const archivedOrganization = domainBuilder.buildOrganization({ archivedAt: '2022-02-02' }); - const emails = ['member01@organization.org']; - const locale = 'fr-fr'; - const role = Membership.roles.MEMBER; - - const organizationInvitationRepository = sinon.stub(); - const organizationRepository = { - get: sinon.stub().resolves(archivedOrganization), - }; - const organizationInvitationService = { createOrUpdateOrganizationInvitation: sinon.stub() }; - - // when - const error = await catchErr(createOrganizationInvitationByAdmin)({ - organizationId: archivedOrganization.id, - emails, - locale, - role, - organizationRepository, - organizationInvitationRepository, - organizationInvitationService, - }); - - // then - expect(error).to.be.instanceOf(OrganizationArchivedError); - expect(error.message).to.be.equal("L'organisation est archivée."); - expect(organizationInvitationService.createOrUpdateOrganizationInvitation).to.not.have.been.called; - }); - }); -}); diff --git a/api/tests/team/unit/domain/usecases/create-organization-invitations.usecase.test.js b/api/tests/team/unit/domain/usecases/create-organization-invitations.usecase.test.js deleted file mode 100644 index 374eab8f9d2..00000000000 --- a/api/tests/team/unit/domain/usecases/create-organization-invitations.usecase.test.js +++ /dev/null @@ -1,88 +0,0 @@ -import { OrganizationArchivedError } from '../../../../../src/team/domain/errors.js'; -import { createOrganizationInvitations } from '../../../../../src/team/domain/usecases/create-organization-invitations.usecase.js'; -import { catchErr, domainBuilder, expect, sinon } from '../../../../test-helper.js'; - -describe('Unit | Team | Domain | UseCase | create-organization-invitations', function () { - let organizationInvitationRepository, organizationRepository, organizationInvitationService; - - beforeEach(function () { - const organization = domainBuilder.buildOrganization(); - organizationRepository = { - get: sinon.stub(), - }; - organizationRepository.get.resolves(organization); - - organizationInvitationService = { - createOrUpdateOrganizationInvitation: sinon.stub(), - }; - organizationInvitationService.createOrUpdateOrganizationInvitation.resolves(); - }); - - describe('#createOrganizationInvitations', function () { - it('should create one organization-invitation with organizationId and email', async function () { - // given - const organizationId = 1; - const emails = ['member@organization.org']; - const locale = 'fr-fr'; - - // when - await createOrganizationInvitations({ - organizationId, - emails, - locale, - organizationRepository, - organizationInvitationRepository, - organizationInvitationService, - }); - - // then - expect(organizationInvitationService.createOrUpdateOrganizationInvitation).to.has.been.calledOnce; - expect(organizationInvitationService.createOrUpdateOrganizationInvitation).to.has.been.calledWithExactly({ - organizationId, - email: emails[0], - locale, - organizationRepository, - organizationInvitationRepository, - }); - }); - - it('should delete spaces and duplicated emails, and create two organization-invitations', async function () { - // given - const organizationId = 2; - const emails = ['member01@organization.org', ' member01@organization.org', 'member02@organization.org']; - - // when - await createOrganizationInvitations({ - organizationId, - emails, - organizationRepository, - organizationInvitationRepository, - organizationInvitationService, - }); - - // then - expect(organizationInvitationService.createOrUpdateOrganizationInvitation).to.has.been.calledTwice; - }); - - it('should throw an organization archived error when it is archived', async function () { - // given - const archivedOrganization = domainBuilder.buildOrganization({ archivedAt: '2022-02-02' }); - const emails = ['member01@organization.org']; - organizationRepository.get.resolves(archivedOrganization); - - // when - const error = await catchErr(createOrganizationInvitations)({ - organizationId: archivedOrganization.id, - emails, - organizationRepository, - organizationInvitationRepository, - organizationInvitationService, - }); - - // then - expect(error).to.be.instanceOf(OrganizationArchivedError); - expect(error.message).to.be.equal("L'organisation est archivée."); - expect(organizationInvitationService.createOrUpdateOrganizationInvitation).to.not.have.been.called; - }); - }); -});