From 97caee09458a75eb2e2f51bef53d3eb9e9d4171e Mon Sep 17 00:00:00 2001 From: celineung Date: Tue, 24 Sep 2024 16:04:23 +0200 Subject: [PATCH] add route to remove user from agency --- .../routers/admin/createAdminRouter.ts | 25 +- back/src/config/bootstrap/createUseCases.ts | 5 + ...cyWontHaveEnoughCounsellorsOrValidators.ts | 44 +++ .../use-cases/RemoveUserFromAgency.ts | 84 ++++++ .../RemoveUserFromAgency.unit.test.ts | 257 ++++++++++++++++++ .../use-cases/UpdateUserForAgency.ts | 58 ++-- shared/src/admin/admin.dto.ts | 5 + shared/src/admin/admin.routes.ts | 11 + shared/src/admin/admin.schema.ts | 7 + shared/src/errors/errors.ts | 7 + 10 files changed, 464 insertions(+), 39 deletions(-) create mode 100644 back/src/domains/inclusion-connected-users/helpers/throwIfAgencyWontHaveEnoughCounsellorsOrValidators.ts create mode 100644 back/src/domains/inclusion-connected-users/use-cases/RemoveUserFromAgency.ts create mode 100644 back/src/domains/inclusion-connected-users/use-cases/RemoveUserFromAgency.unit.test.ts diff --git a/back/src/adapters/primary/routers/admin/createAdminRouter.ts b/back/src/adapters/primary/routers/admin/createAdminRouter.ts index bdc7ac2b5f..fc7c48285e 100644 --- a/back/src/adapters/primary/routers/admin/createAdminRouter.ts +++ b/back/src/adapters/primary/routers/admin/createAdminRouter.ts @@ -1,5 +1,11 @@ import { Router } from "express"; -import { GetDashboardParams, adminRoutes, agencyRoutes, errors } from "shared"; +import { + GetDashboardParams, + RemoveAgencyUserParams, + adminRoutes, + agencyRoutes, + errors, +} from "shared"; import { BadRequestError } from "shared"; import { createExpressSharedRouter } from "shared-routes/express"; import type { AppDependencies } from "../../../../config/bootstrap/createAppDependencies"; @@ -95,6 +101,23 @@ export const createAdminRouter = (deps: AppDependencies): Router => { }), ); + sharedAdminRouter.removeUserFromAgency( + deps.inclusionConnectAuthMiddleware, + (req, res) => + sendHttpResponse(req, res, async () => { + const currentUser = req.payloads?.currentUser; + if (!currentUser) throw errors.user.unauthorized(); + const userWithAgency: RemoveAgencyUserParams = { + agencyId: req.params.agencyId, + userId: req.params.userId, + }; + return await deps.useCases.removeUserFromAgency.execute( + userWithAgency, + currentUser, + ); + }), + ); + sharedAdminRouter.rejectIcUserForAgency( deps.inclusionConnectAuthMiddleware, (req, res) => diff --git a/back/src/config/bootstrap/createUseCases.ts b/back/src/config/bootstrap/createUseCases.ts index d882f72168..fe949b2f62 100644 --- a/back/src/config/bootstrap/createUseCases.ts +++ b/back/src/config/bootstrap/createUseCases.ts @@ -122,6 +122,7 @@ import { GetInclusionConnectedUser } from "../../domains/inclusion-connected-use import { GetInclusionConnectedUsers } from "../../domains/inclusion-connected-users/use-cases/GetInclusionConnectedUsers"; import { LinkFranceTravailUsersToTheirAgencies } from "../../domains/inclusion-connected-users/use-cases/LinkFranceTravailUsersToTheirAgencies"; import { RejectIcUserForAgency } from "../../domains/inclusion-connected-users/use-cases/RejectIcUserForAgency"; +import { makeRemoveUserFromAgency } from "../../domains/inclusion-connected-users/use-cases/RemoveUserFromAgency"; import { UpdateUserForAgency } from "../../domains/inclusion-connected-users/use-cases/UpdateUserForAgency"; import { makeUpdateMarketingEstablishmentContactList } from "../../domains/marketing/use-cases/UpdateMarketingEstablishmentContactsList"; import { AppConfig } from "./appConfig"; @@ -639,6 +640,10 @@ export const createUseCases = ( createNewEvent, }, }), + removeUserFromAgency: makeRemoveUserFromAgency({ + uowPerformer, + deps: { createNewEvent }, + }), broadcastConventionAgain: makeBroadcastConventionAgain({ uowPerformer, deps: { createNewEvent, timeGateway: gateways.timeGateway }, diff --git a/back/src/domains/inclusion-connected-users/helpers/throwIfAgencyWontHaveEnoughCounsellorsOrValidators.ts b/back/src/domains/inclusion-connected-users/helpers/throwIfAgencyWontHaveEnoughCounsellorsOrValidators.ts new file mode 100644 index 0000000000..4a118acd02 --- /dev/null +++ b/back/src/domains/inclusion-connected-users/helpers/throwIfAgencyWontHaveEnoughCounsellorsOrValidators.ts @@ -0,0 +1,44 @@ +import { AgencyDto, UserId, errors } from "shared"; +import { UnitOfWork } from "../../core/unit-of-work/ports/UnitOfWork"; + +export const throwIfAgencyDontHaveOtherValidatorsReceivingNotifications = + async (uow: UnitOfWork, agency: AgencyDto, userId: UserId) => { + if (agency.refersToAgencyId !== null) return; + + const agencyUsers = await uow.userRepository.getWithFilter({ + agencyId: agency.id, + }); + + const agencyHasOtherValidator = agencyUsers.some( + (agencyUser) => + agencyUser.id !== userId && + agencyUser.agencyRights.some( + (right) => + right.isNotifiedByEmail && right.roles.includes("validator"), + ), + ); + + if (!agencyHasOtherValidator) + throw errors.agency.notEnoughValidators({ agencyId: agency.id }); + }; + +export const throwIfAgencyDontHaveOtherCounsellorsReceivingNotifications = + async (uow: UnitOfWork, agency: AgencyDto, userId: UserId) => { + if (!agency.refersToAgencyId) return; + + const agencyUsers = await uow.userRepository.getWithFilter({ + agencyId: agency.id, + }); + + const agencyHasOtherCounsellor = agencyUsers.some( + (agencyUser) => + agencyUser.id !== userId && + agencyUser.agencyRights.some( + (right) => + right.isNotifiedByEmail && right.roles.includes("counsellor"), + ), + ); + + if (!agencyHasOtherCounsellor) + throw errors.agency.notEnoughCounsellors({ agencyId: agency.id }); + }; diff --git a/back/src/domains/inclusion-connected-users/use-cases/RemoveUserFromAgency.ts b/back/src/domains/inclusion-connected-users/use-cases/RemoveUserFromAgency.ts new file mode 100644 index 0000000000..d819d93758 --- /dev/null +++ b/back/src/domains/inclusion-connected-users/use-cases/RemoveUserFromAgency.ts @@ -0,0 +1,84 @@ +import { + AgencyDto, + AgencyId, + InclusionConnectedUser, + RemoveAgencyUserParams, + UserId, + errors, + removeAgencyUserParamsSchema, +} from "shared"; +import { createTransactionalUseCase } from "../../core/UseCase"; +import { UserRepository } from "../../core/authentication/inclusion-connect/port/UserRepository"; +import { CreateNewEvent } from "../../core/events/ports/EventBus"; +import { + throwIfAgencyDontHaveOtherCounsellorsReceivingNotifications, + throwIfAgencyDontHaveOtherValidatorsReceivingNotifications, +} from "../helpers/throwIfAgencyWontHaveEnoughCounsellorsOrValidators"; +import { throwIfNotAdmin } from "../helpers/throwIfIcUserNotBackofficeAdmin"; + +export type RemoveUserFromAgency = ReturnType; + +const getUserAndThrowIfNotFound = async ( + userRepository: UserRepository, + userId: UserId, +): Promise => { + const requestedUser = await userRepository.getById(userId); + if (!requestedUser) throw errors.user.notFound({ userId }); + return requestedUser; +}; + +const getAgencyAndThrowIfUserHasNoAgencyRight = ( + user: InclusionConnectedUser, + agencyId: AgencyId, +): AgencyDto => { + const userRight = user.agencyRights.find( + (agencyRight) => agencyRight.agency.id === agencyId, + ); + + if (!userRight) + throw errors.user.expectedRightsOnAgency({ + agencyId, + userId: user.id, + }); + + return userRight.agency; +}; + +export const makeRemoveUserFromAgency = createTransactionalUseCase< + RemoveAgencyUserParams, + void, + InclusionConnectedUser, + { createNewEvent: CreateNewEvent } +>( + { name: "RemoveUserFromAgency", inputSchema: removeAgencyUserParamsSchema }, + async ({ currentUser, uow, inputParams }) => { + throwIfNotAdmin(currentUser); + const requestedUser = await getUserAndThrowIfNotFound( + uow.userRepository, + inputParams.userId, + ); + const agency = getAgencyAndThrowIfUserHasNoAgencyRight( + requestedUser, + inputParams.agencyId, + ); + await throwIfAgencyDontHaveOtherValidatorsReceivingNotifications( + uow, + agency, + inputParams.userId, + ); + await throwIfAgencyDontHaveOtherCounsellorsReceivingNotifications( + uow, + agency, + inputParams.userId, + ); + + const filteredAgencyRights = requestedUser.agencyRights.filter( + (agencyRight) => agencyRight.agency.id !== inputParams.agencyId, + ); + + await uow.userRepository.updateAgencyRights({ + userId: inputParams.userId, + agencyRights: filteredAgencyRights, + }); + }, +); diff --git a/back/src/domains/inclusion-connected-users/use-cases/RemoveUserFromAgency.unit.test.ts b/back/src/domains/inclusion-connected-users/use-cases/RemoveUserFromAgency.unit.test.ts new file mode 100644 index 0000000000..ac7d216d17 --- /dev/null +++ b/back/src/domains/inclusion-connected-users/use-cases/RemoveUserFromAgency.unit.test.ts @@ -0,0 +1,257 @@ +import { + AgencyDto, + AgencyDtoBuilder, + AgencyRight, + InclusionConnectedUser, + InclusionConnectedUserBuilder, + RemoveAgencyUserParams, + errors, + expectPromiseToFailWithError, +} from "shared"; +import { InMemoryAgencyRepository } from "../../agency/adapters/InMemoryAgencyRepository"; +import { InMemoryUserRepository } from "../../core/authentication/inclusion-connect/adapters/InMemoryUserRepository"; +import { + CreateNewEvent, + makeCreateNewEvent, +} from "../../core/events/ports/EventBus"; +import { CustomTimeGateway } from "../../core/time-gateway/adapters/CustomTimeGateway"; +import { TimeGateway } from "../../core/time-gateway/ports/TimeGateway"; +import { InMemoryUowPerformer } from "../../core/unit-of-work/adapters/InMemoryUowPerformer"; +import { createInMemoryUow } from "../../core/unit-of-work/adapters/createInMemoryUow"; +import { TestUuidGenerator } from "../../core/uuid-generator/adapters/UuidGeneratorImplementations"; +import { UuidGenerator } from "../../core/uuid-generator/ports/UuidGenerator"; +import { + RemoveUserFromAgency, + makeRemoveUserFromAgency, +} from "./RemoveUserFromAgency"; + +const agency = new AgencyDtoBuilder() + .withCounsellorEmails(["fake-email@gmail.com"]) + .build(); + +const backofficeAdminUser = new InclusionConnectedUserBuilder() + .withId("backoffice-admin-id") + .withIsAdmin(true) + .build(); + +const notAdminUser = new InclusionConnectedUserBuilder() + .withId("not-admin-id") + .withIsAdmin(false) + .build(); + +describe("RemoveUserFromAgency", () => { + let timeGateway: TimeGateway; + let uuidGenerator: UuidGenerator; + let createNewEvent: CreateNewEvent; + let removeUserFromAgency: RemoveUserFromAgency; + let userRepository: InMemoryUserRepository; + let agencyRepository: InMemoryAgencyRepository; + + beforeEach(() => { + const uow = createInMemoryUow(); + const uowPerformer = new InMemoryUowPerformer(uow); + timeGateway = new CustomTimeGateway(); + uuidGenerator = new TestUuidGenerator(); + createNewEvent = makeCreateNewEvent({ + uuidGenerator, + timeGateway, + }); + userRepository = uow.userRepository; + agencyRepository = uow.agencyRepository; + userRepository.setInclusionConnectedUsers([ + backofficeAdminUser, + notAdminUser, + ]); + agencyRepository.setAgencies([agency]); + removeUserFromAgency = makeRemoveUserFromAgency({ + uowPerformer, + deps: { createNewEvent }, + }); + }); + + it("throws forbidden when token payload is not backoffice token", () => { + expectPromiseToFailWithError( + removeUserFromAgency.execute( + { + agencyId: "agency-id", + userId: "user-id", + }, + notAdminUser, + ), + errors.user.forbidden({ userId: notAdminUser.id }), + ); + }); + + it("throws notFound if user to delete not found", async () => { + const inputParams: RemoveAgencyUserParams = { + agencyId: agency.id, + userId: "unexisting-user", + }; + + expectPromiseToFailWithError( + removeUserFromAgency.execute(inputParams, backofficeAdminUser), + errors.user.notFound({ userId: inputParams.userId }), + ); + }); + + it("throws forbidden if user to delete has not rights on agency", async () => { + const inputParams: RemoveAgencyUserParams = { + agencyId: agency.id, + userId: notAdminUser.id, + }; + + expectPromiseToFailWithError( + removeUserFromAgency.execute(inputParams, backofficeAdminUser), + errors.user.expectedRightsOnAgency(inputParams), + ); + }); + + it("throws forbidden if user to delete is the last validator receiving notifications", async () => { + const initialAgencyRights: AgencyRight[] = [ + { + agency, + roles: ["validator"], + isNotifiedByEmail: true, + }, + ]; + const user: InclusionConnectedUser = { + ...notAdminUser, + agencyRights: initialAgencyRights, + dashboards: { + agencies: {}, + establishments: {}, + }, + }; + userRepository.setInclusionConnectedUsers([user]); + const inputParams: RemoveAgencyUserParams = { + agencyId: agency.id, + userId: notAdminUser.id, + }; + + expectPromiseToFailWithError( + removeUserFromAgency.execute(inputParams, backofficeAdminUser), + errors.agency.notEnoughValidators(inputParams), + ); + }); + + it("throws forbidden if user to delete is the last validator receiving notifications", async () => { + const initialAgencyRights: AgencyRight[] = [ + { + agency, + roles: ["validator"], + isNotifiedByEmail: true, + }, + ]; + const user: InclusionConnectedUser = { + ...notAdminUser, + agencyRights: initialAgencyRights, + dashboards: { + agencies: {}, + establishments: {}, + }, + }; + userRepository.setInclusionConnectedUsers([user]); + const inputParams: RemoveAgencyUserParams = { + agencyId: agency.id, + userId: notAdminUser.id, + }; + + expectPromiseToFailWithError( + removeUserFromAgency.execute(inputParams, backofficeAdminUser), + errors.agency.notEnoughValidators(inputParams), + ); + }); + + it("throws forbidden if user to delete is the last counsellor receiving notifications", async () => { + const agencyWithRefersTo: AgencyDto = { + ...agency, + id: "agency-with-refers-to-id", + counsellorEmails: [], + validatorEmails: [], + refersToAgencyId: agency.id, + }; + const initialAgencyRights: AgencyRight[] = [ + { + agency: agencyWithRefersTo, + roles: ["validator"], + isNotifiedByEmail: true, + }, + ]; + const user: InclusionConnectedUser = { + ...notAdminUser, + agencyRights: initialAgencyRights, + dashboards: { + agencies: {}, + establishments: {}, + }, + }; + userRepository.setInclusionConnectedUsers([user]); + const inputParams: RemoveAgencyUserParams = { + agencyId: agencyWithRefersTo.id, + userId: notAdminUser.id, + }; + + expectPromiseToFailWithError( + removeUserFromAgency.execute(inputParams, backofficeAdminUser), + errors.agency.notEnoughCounsellors(inputParams), + ); + }); + + describe("user to delete has right on agency", () => { + it("remove user from agency", async () => { + const agency2 = new AgencyDtoBuilder().withId("agency-2-id").build(); + const initialAgencyRights: AgencyRight[] = [ + { + agency, + roles: ["validator"], + isNotifiedByEmail: true, + }, + { + agency: agency2, + roles: ["validator"], + isNotifiedByEmail: true, + }, + ]; + const user: InclusionConnectedUser = { + ...notAdminUser, + agencyRights: initialAgencyRights, + dashboards: { + agencies: {}, + establishments: {}, + }, + }; + const otherUserWithRightOnAgencies: InclusionConnectedUser = { + ...notAdminUser, + id: "other-user-id", + agencyRights: initialAgencyRights, + dashboards: { + agencies: {}, + establishments: {}, + }, + }; + userRepository.setInclusionConnectedUsers([ + user, + otherUserWithRightOnAgencies, + ]); + expect( + (await userRepository.getById(notAdminUser.id))?.agencyRights, + ).toEqual(initialAgencyRights); + + const inputParams: RemoveAgencyUserParams = { + agencyId: agency.id, + userId: notAdminUser.id, + }; + await removeUserFromAgency.execute(inputParams, backofficeAdminUser); + + expect( + (await userRepository.getById(inputParams.userId))?.agencyRights, + ).toEqual([ + { + agency: agency2, + roles: ["validator"], + isNotifiedByEmail: true, + }, + ]); + }); + }); +}); diff --git a/back/src/domains/inclusion-connected-users/use-cases/UpdateUserForAgency.ts b/back/src/domains/inclusion-connected-users/use-cases/UpdateUserForAgency.ts index 519dd2d52d..0a20be08f9 100644 --- a/back/src/domains/inclusion-connected-users/use-cases/UpdateUserForAgency.ts +++ b/back/src/domains/inclusion-connected-users/use-cases/UpdateUserForAgency.ts @@ -14,32 +14,23 @@ import { DomainEvent } from "../../core/events/events"; import { CreateNewEvent } from "../../core/events/ports/EventBus"; import { UnitOfWork } from "../../core/unit-of-work/ports/UnitOfWork"; import { UnitOfWorkPerformer } from "../../core/unit-of-work/ports/UnitOfWorkPerformer"; +import { + throwIfAgencyDontHaveOtherCounsellorsReceivingNotifications, + throwIfAgencyDontHaveOtherValidatorsReceivingNotifications, +} from "../helpers/throwIfAgencyWontHaveEnoughCounsellorsOrValidators"; import { throwIfNotAdmin } from "../helpers/throwIfIcUserNotBackofficeAdmin"; -const rejectIfAgencyWontHaveValidators = async ( +const rejectIfAgencyWontHaveValidatorsReceivingNotifications = async ( uow: UnitOfWork, params: UserParamsForAgency, agency: AgencyDto, ) => { - if ( - (!params.roles.includes("validator") || !params.isNotifiedByEmail) && - agency.refersToAgencyId === null - ) { - const agencyUsers = await uow.userRepository.getWithFilter({ - agencyId: params.agencyId, - }); - - const agencyHasOtherValidator = agencyUsers.some( - (agencyUser) => - agencyUser.id !== params.userId && - agencyUser.agencyRights.some( - (right) => - right.isNotifiedByEmail && right.roles.includes("validator"), - ), + if (!params.roles.includes("validator") || !params.isNotifiedByEmail) { + await throwIfAgencyDontHaveOtherValidatorsReceivingNotifications( + uow, + agency, + params.userId, ); - - if (!agencyHasOtherValidator) - throw errors.agency.notEnoughValidators({ agencyId: params.agencyId }); } }; @@ -63,25 +54,12 @@ const rejectIfAgencyWithRefersToWontHaveCounsellors = async ( params: UserParamsForAgency, agency: AgencyDto, ) => { - if ( - (!params.roles.includes("counsellor") || !params.isNotifiedByEmail) && - agency.refersToAgencyId - ) { - const agencyUsers = await uow.userRepository.getWithFilter({ - agencyId: params.agencyId, - }); - - const agencyHasOtherCounsellor = agencyUsers.some( - (agencyUser) => - agencyUser.id !== params.userId && - agencyUser.agencyRights.some( - (right) => - right.isNotifiedByEmail && right.roles.includes("counsellor"), - ), + if (!params.roles.includes("counsellor") || !params.isNotifiedByEmail) { + await throwIfAgencyDontHaveOtherCounsellorsReceivingNotifications( + uow, + agency, + params.userId, ); - - if (!agencyHasOtherCounsellor) - throw errors.agency.notEnoughCounsellors({ agencyId: params.agencyId }); } }; @@ -108,7 +86,11 @@ const makeAgencyRights = async ( params, agency, ); - await rejectIfAgencyWontHaveValidators(uow, params, agency); + await rejectIfAgencyWontHaveValidatorsReceivingNotifications( + uow, + params, + agency, + ); await rejectIfAgencyWithRefersToWontHaveCounsellors(uow, params, agency); const updatedAgencyRight: AgencyRight = { diff --git a/shared/src/admin/admin.dto.ts b/shared/src/admin/admin.dto.ts index fb82bc930c..e07868e42c 100644 --- a/shared/src/admin/admin.dto.ts +++ b/shared/src/admin/admin.dto.ts @@ -12,6 +12,11 @@ import { import { SiretDto } from "../siret/siret"; import { OmitFromExistingKeys } from "../utils"; +export type RemoveAgencyUserParams = { + agencyId: AgencyId; + userId: UserId; +}; + export type UserParamsForAgency = { agencyId: AgencyId; roles: AgencyRole[]; diff --git a/shared/src/admin/admin.routes.ts b/shared/src/admin/admin.routes.ts index 67c78b54b8..3b27e508ab 100644 --- a/shared/src/admin/admin.routes.ts +++ b/shared/src/admin/admin.routes.ts @@ -59,6 +59,17 @@ export const adminRoutes = defineRoutes({ 404: httpErrorSchema, }, }), + removeUserFromAgency: defineRoute({ + method: "delete", + url: "/admin/inclusion-connected/users/:userId/agency/:agencyId", + ...withAuthorizationHeaders, + responses: { + 200: expressEmptyResponseBody, + 400: httpErrorSchema, + 401: httpErrorSchema, + 404: httpErrorSchema, + }, + }), createUserForAgency: defineRoute({ method: "post", diff --git a/shared/src/admin/admin.schema.ts b/shared/src/admin/admin.schema.ts index 0bea5f1680..1ae3268f18 100644 --- a/shared/src/admin/admin.schema.ts +++ b/shared/src/admin/admin.schema.ts @@ -12,10 +12,17 @@ import { ManageConventionAdminForm, ManageEstablishmentAdminForm, RejectIcUserRoleForAgencyParams, + RemoveAgencyUserParams, UserParamsForAgency, WithUserFilters, } from "./admin.dto"; +export const removeAgencyUserParamsSchema: z.Schema = + z.object({ + agencyId: agencyIdSchema, + userId: userIdSchema, + }); + export const userParamsForAgencySchema: z.Schema = z.object({ agencyId: agencyIdSchema, diff --git a/shared/src/errors/errors.ts b/shared/src/errors/errors.ts index de4146b458..e1778c3f0d 100644 --- a/shared/src/errors/errors.ts +++ b/shared/src/errors/errors.ts @@ -313,6 +313,13 @@ export const errors = { new BadRequestError( `L'utilisateur qui a l'identifiant "${userId}" a déjà les droits pour cette agence.`, ), + expectedRightsOnAgency: ({ + agencyId, + userId, + }: { userId: UserId; agencyId: AgencyId }) => + new BadRequestError( + `L'utilisateur qui a l'identifiant "${userId}" n'a pas de droits sur l'agence "${agencyId}".`, + ), noRightsOnAgency: ({ agencyId, userId,