diff --git a/back/package.json b/back/package.json index ec0c3f7ed2..445d25ebd1 100644 --- a/back/package.json +++ b/back/package.json @@ -36,6 +36,7 @@ "trigger-delete-old-discussion-messages": "ts-node src/scripts/triggerDeleteOldDiscussionMessages", "trigger-refresh-materialized-views": "ts-node src/scripts/triggerRefreshMaterializedViews.ts", "trigger-resync-old-conventions-to-pe": "ts-node src/scripts/triggerResyncOldConventionsToPe.ts", + "trigger-assessment-reminders": "ts-node src/scripts/triggerAssessmentReminder.ts", "trigger-sending-emails-with-assessment-creation-link": "ts-node src/scripts/triggerSendingEmailsWithAssessmentCreationLink.ts", "trigger-sending-beneficiary-assessment-emails": "ts-node src/scripts/triggerSendingBeneficiaryPdfAssessmentEmails.ts", "trigger-suggest-edit-form-establishment-every-6-months": "ts-node src/scripts/triggerSuggestEditFormEstablishmentEvery6Months.ts", diff --git a/back/scalingo/cron.json b/back/scalingo/cron.json index f7fc336262..42254a067a 100644 --- a/back/scalingo/cron.json +++ b/back/scalingo/cron.json @@ -40,6 +40,10 @@ "command": "0 21 * * * pnpm back run trigger-delete-email-attachements", "size": "M" }, + { + "command": "15 21 * * * pnpm back run trigger-assessment-reminders", + "size": "M" + }, { "command": "0 22 * * * pnpm back run trigger-mark-old-convention-as-deprecated", "size": "M" diff --git a/back/src/config/bootstrap/createUseCases.ts b/back/src/config/bootstrap/createUseCases.ts index 9e5c559797..5ca9b76fd8 100644 --- a/back/src/config/bootstrap/createUseCases.ts +++ b/back/src/config/bootstrap/createUseCases.ts @@ -96,6 +96,7 @@ import { UuidGenerator } from "../../domains/core/uuid-generator/ports/UuidGener import { AddEstablishmentLead } from "../../domains/establishment/use-cases/AddEstablishmentLead"; import { AddFormEstablishment } from "../../domains/establishment/use-cases/AddFormEstablishment"; import { AddFormEstablishmentBatch } from "../../domains/establishment/use-cases/AddFormEstablismentsBatch"; +import { makeAssessmentReminder } from "../../domains/establishment/use-cases/AssessmentReminder"; import { ContactEstablishment } from "../../domains/establishment/use-cases/ContactEstablishment"; import { makeContactRequestReminder } from "../../domains/establishment/use-cases/ContactRequestReminder"; import { DeleteEstablishment } from "../../domains/establishment/use-cases/DeleteEstablishment"; @@ -633,6 +634,14 @@ export const createUseCases = ( })), }), + assessmentReminder: makeAssessmentReminder({ + uowPerformer, + deps: { + timeGateway: gateways.timeGateway, + saveNotificationAndRelatedEvent, + generateConventionMagicLinkUrl, + }, + }), getAgencyById: makeGetAgencyById({ uowPerformer, }), diff --git a/back/src/domains/convention/adapters/InMemoryAssessmentRepository.ts b/back/src/domains/convention/adapters/InMemoryAssessmentRepository.ts index 7efcd32ece..411ab4e2c1 100644 --- a/back/src/domains/convention/adapters/InMemoryAssessmentRepository.ts +++ b/back/src/domains/convention/adapters/InMemoryAssessmentRepository.ts @@ -18,6 +18,14 @@ export class InMemoryAssessmentRepository implements AssessmentRepository { ); } + public async getByConventionIds( + conventionIds: ConventionId[], + ): Promise { + return this.#assessments.filter((assessment) => + conventionIds.includes(assessment.conventionId), + ); + } + public async save(assessment: AssessmentEntity): Promise { this.#assessments.push(assessment); } diff --git a/back/src/domains/convention/adapters/InMemoryConventionRepository.ts b/back/src/domains/convention/adapters/InMemoryConventionRepository.ts index eb77f6ab5b..0ccd580189 100644 --- a/back/src/domains/convention/adapters/InMemoryConventionRepository.ts +++ b/back/src/domains/convention/adapters/InMemoryConventionRepository.ts @@ -1,3 +1,5 @@ +import { addDays } from "date-fns"; +import subDays from "date-fns/subDays"; import { values } from "ramda"; import { ConventionDto, @@ -5,6 +7,7 @@ import { ConventionReadDto, Email, errors, + validatedConventionStatuses, } from "shared"; import { ConventionRepository } from "../ports/ConventionRepository"; @@ -19,6 +22,19 @@ export class InMemoryConventionRepository implements ConventionRepository { throw new Error("not implemented"); } + public async getIdsValidatedByEndDateAround( + dateEnd: Date, + ): Promise { + return values(this.#conventions) + .filter( + (convention) => + validatedConventionStatuses.includes(convention.status) && + new Date(convention.dateEnd) >= subDays(dateEnd, 1) && + new Date(convention.dateEnd) <= addDays(dateEnd, 1), + ) + .map((convention) => convention.id); + } + public async getById(id: ConventionId) { return this.#conventions[id]; } diff --git a/back/src/domains/convention/adapters/PgAssessmentRepository.integration.test.ts b/back/src/domains/convention/adapters/PgAssessmentRepository.integration.test.ts index ba90f301b5..5c2c01e8df 100644 --- a/back/src/domains/convention/adapters/PgAssessmentRepository.integration.test.ts +++ b/back/src/domains/convention/adapters/PgAssessmentRepository.integration.test.ts @@ -165,4 +165,17 @@ describe("PgAssessmentRepository", () => { ); }); }); + + describe("getByConventionIds", () => { + it("returns assessment found", async () => { + await assessmentRepository.save(minimalAssessment); + + expectToEqual( + await assessmentRepository.getByConventionIds([ + minimalAssessment.conventionId, + ]), + [minimalAssessment], + ); + }); + }); }); diff --git a/back/src/domains/convention/adapters/PgAssessmentRepository.ts b/back/src/domains/convention/adapters/PgAssessmentRepository.ts index f404ca003e..d3b9161e08 100644 --- a/back/src/domains/convention/adapters/PgAssessmentRepository.ts +++ b/back/src/domains/convention/adapters/PgAssessmentRepository.ts @@ -13,52 +13,58 @@ import { import { AssessmentEntity } from "../entities/AssessmentEntity"; import { AssessmentRepository } from "../ports/AssessmentRepository"; +const createAssessmentQueryBuilder = (transaction: KyselyDb) => { + return transaction.selectFrom("immersion_assessments").select((eb) => [ + jsonBuildObject({ + conventionId: eb.ref("convention_id"), + status: eb.ref("status").$castTo(), + establishmentFeedback: eb.ref("establishment_feedback"), + establishmentAdvices: eb.ref("establishment_advices"), + endedWithAJob: eb.ref("ended_with_a_job"), + contractStartDate: sql`date_to_iso(contract_start_date)`, + typeOfContract: eb.ref("type_of_contract"), + lastDayOfPresence: sql`date_to_iso(last_day_of_presence)`, + numberOfMissedHours: eb.ref("number_of_missed_hours"), + }).as("assessment"), + ]); +}; + +const parseAssessmentSchema = (assessment: any) => { + return assessmentSchema.parse({ + conventionId: assessment.conventionId, + status: assessment.status, + establishmentFeedback: assessment.establishmentFeedback, + establishmentAdvices: assessment.establishmentAdvices, + endedWithAJob: assessment.endedWithAJob, + ...(assessment.contractStartDate + ? { contractStartDate: assessment.contractStartDate } + : {}), + ...(assessment.typeOfContract + ? { typeOfContract: assessment.typeOfContract } + : {}), + ...(assessment.lastDayOfPresence + ? { lastDayOfPresence: assessment.lastDayOfPresence } + : {}), + ...(assessment.numberOfMissedHours !== null + ? { numberOfMissedHours: assessment.numberOfMissedHours } + : {}), + }); +}; + export class PgAssessmentRepository implements AssessmentRepository { constructor(private transaction: KyselyDb) {} public async getByConventionId( conventionId: ConventionId, ): Promise { - const result = await this.transaction - .selectFrom("immersion_assessments") - .select((eb) => [ - jsonBuildObject({ - conventionId: eb.ref("convention_id"), - status: eb.ref("status").$castTo(), - establishmentFeedback: eb.ref("establishment_feedback"), - establishmentAdvices: eb.ref("establishment_advices"), - endedWithAJob: eb.ref("ended_with_a_job"), - contractStartDate: sql`date_to_iso(contract_start_date)`, - typeOfContract: eb.ref("type_of_contract"), - lastDayOfPresence: sql`date_to_iso(last_day_of_presence)`, - numberOfMissedHours: eb.ref("number_of_missed_hours"), - }).as("assessment"), - ]) + const result = await createAssessmentQueryBuilder(this.transaction) .where("convention_id", "=", conventionId) .executeTakeFirst(); const assessment = result?.assessment; if (!assessment) return; - const dto = assessmentSchema.parse({ - conventionId: assessment.conventionId, - status: assessment.status, - establishmentFeedback: assessment.establishmentFeedback, - establishmentAdvices: assessment.establishmentAdvices, - endedWithAJob: assessment.endedWithAJob, - ...(assessment.contractStartDate - ? { contractStartDate: assessment.contractStartDate } - : {}), - ...(assessment.typeOfContract - ? { typeOfContract: assessment.typeOfContract } - : {}), - ...(assessment.lastDayOfPresence - ? { lastDayOfPresence: assessment.lastDayOfPresence } - : {}), - ...(assessment.numberOfMissedHours !== null - ? { numberOfMissedHours: assessment.numberOfMissedHours } - : {}), - }); + const dto = parseAssessmentSchema(assessment); return { _entityName: "Assessment", @@ -66,6 +72,23 @@ export class PgAssessmentRepository implements AssessmentRepository { }; } + public async getByConventionIds( + conventionIds: ConventionId[], + ): Promise { + if (conventionIds.length === 0) return []; + + const result = await createAssessmentQueryBuilder(this.transaction) + .where("convention_id", "in", conventionIds) + .execute(); + + return result.map(({ assessment }) => { + return { + _entityName: "Assessment", + ...parseAssessmentSchema(assessment), + }; + }); + } + public async save(assessmentEntity: AssessmentEntity): Promise { await this.transaction .insertInto("immersion_assessments") diff --git a/back/src/domains/convention/adapters/PgConventionRepository.integration.test.ts b/back/src/domains/convention/adapters/PgConventionRepository.integration.test.ts index 4f83d8ba01..6118b7de2e 100644 --- a/back/src/domains/convention/adapters/PgConventionRepository.integration.test.ts +++ b/back/src/domains/convention/adapters/PgConventionRepository.integration.test.ts @@ -21,6 +21,7 @@ import { KyselyDb, makeKyselyDb } from "../../../config/pg/kysely/kyselyUtils"; import { getTestPgPool } from "../../../config/pg/pgUtils"; import { toAgencyWithRights } from "../../../utils/agency"; import { PgAgencyRepository } from "../../agency/adapters/PgAgencyRepository"; +import { CustomTimeGateway } from "../../core/time-gateway/adapters/CustomTimeGateway"; import { PgConventionRepository } from "./PgConventionRepository"; describe("PgConventionRepository", () => { @@ -47,6 +48,7 @@ describe("PgConventionRepository", () => { let pool: Pool; let conventionRepository: PgConventionRepository; let db: KyselyDb; + let timeGateway: CustomTimeGateway; beforeAll(async () => { pool = getTestPgPool(); @@ -54,6 +56,7 @@ describe("PgConventionRepository", () => { await new PgAgencyRepository(db).insert( toAgencyWithRights(AgencyDtoBuilder.create().build()), ); + timeGateway = new CustomTimeGateway(); }); afterAll(async () => { @@ -952,6 +955,36 @@ describe("PgConventionRepository", () => { }); }); + describe("getIdsValidatedByEndDateAround", () => { + it("retrieve validated convention ids when endDate match", async () => { + const now = timeGateway.now(); + const convention = new ConventionDtoBuilder() + .withStatus("ACCEPTED_BY_VALIDATOR") + .withDateEnd(now.toISOString()) + .build(); + await conventionRepository.save(convention); + + const result = + await conventionRepository.getIdsValidatedByEndDateAround(now); + + expectToEqual(result, [convention.id]); + }); + + it("retrieve nothing when endDate does not match", async () => { + const now = timeGateway.now(); + const convention = new ConventionDtoBuilder() + .withDateEnd(now.toISOString()) + .build(); + await conventionRepository.save(convention); + + const result = await conventionRepository.getIdsValidatedByEndDateAround( + addDays(now, 2), + ); + + expectToEqual(result, []); + }); + }); + describe("getIdsByEstablishmentTutorEmail", () => { it("retrieve convention id when tutor email match", async () => { const email = "mail@mail.com"; diff --git a/back/src/domains/convention/adapters/PgConventionRepository.ts b/back/src/domains/convention/adapters/PgConventionRepository.ts index a891e99a66..0c1d01da20 100644 --- a/back/src/domains/convention/adapters/PgConventionRepository.ts +++ b/back/src/domains/convention/adapters/PgConventionRepository.ts @@ -1,3 +1,5 @@ +import { addDays } from "date-fns"; +import subDays from "date-fns/subDays"; import { sql } from "kysely"; import { Beneficiary, @@ -12,6 +14,7 @@ import { errors, isBeneficiaryStudent, isEstablishmentTutorIsEstablishmentRepresentative, + validatedConventionStatuses, } from "shared"; import { KyselyDb, falsyToNull } from "../../../config/pg/kysely/kyselyUtils"; import { ConventionRepository } from "../ports/ConventionRepository"; @@ -88,6 +91,26 @@ export class PgConventionRepository implements ConventionRepository { return result.map(({ id }) => id); } + public async getIdsValidatedByEndDateAround(endDate: Date) { + const result = await this.transaction + .selectFrom("conventions") + .select("conventions.id") + .where("conventions.status", "in", validatedConventionStatuses) + .where( + sql`conventions.date_end`, + ">=", + subDays(endDate, 1).toISOString().split("T")[0], + ) + .where( + sql`conventions.date_end`, + "<=", + addDays(endDate, 1).toISOString().split("T")[0], + ) + .execute(); + + return result.map(({ id }) => id); + } + public async save(convention: ConventionDto): Promise { const alreadyExistingConvention = await this.getById(convention.id); if (alreadyExistingConvention) diff --git a/back/src/domains/convention/ports/AssessmentRepository.ts b/back/src/domains/convention/ports/AssessmentRepository.ts index a8b920498a..77859a011f 100644 --- a/back/src/domains/convention/ports/AssessmentRepository.ts +++ b/back/src/domains/convention/ports/AssessmentRepository.ts @@ -6,4 +6,7 @@ export interface AssessmentRepository { getByConventionId( conventionId: ConventionId, ): Promise; + getByConventionIds( + conventionIds: ConventionId[], + ): Promise; } diff --git a/back/src/domains/convention/ports/ConventionRepository.ts b/back/src/domains/convention/ports/ConventionRepository.ts index 8214f2994a..8bef8fc315 100644 --- a/back/src/domains/convention/ports/ConventionRepository.ts +++ b/back/src/domains/convention/ports/ConventionRepository.ts @@ -5,6 +5,7 @@ export interface ConventionRepository { email: Email, ): Promise; getIdsByEstablishmentTutorEmail(email: Email): Promise; + getIdsValidatedByEndDateAround: (endDate: Date) => Promise; save: (conventionDto: ConventionDto) => Promise; getById: (id: ConventionId) => Promise; update: (conventionDto: ConventionDto) => Promise; diff --git a/back/src/domains/establishment/use-cases/AssessmentReminder.ts b/back/src/domains/establishment/use-cases/AssessmentReminder.ts new file mode 100644 index 0000000000..43c98e2603 --- /dev/null +++ b/back/src/domains/establishment/use-cases/AssessmentReminder.ts @@ -0,0 +1,195 @@ +import subDays from "date-fns/subDays"; +import { difference } from "ramda"; +import { + AbsoluteUrl, + AgencyRole, + ConventionId, + ConventionReadDto, + Email, + errors, + executeInSequence, + frontRoutes, + immersionFacileNoReplyEmailSender, +} from "shared"; +import { z } from "zod"; +import { GenerateConventionMagicLinkUrl } from "../../../config/bootstrap/magicLinkUrl"; +import { AssessmentRepository } from "../../convention/ports/AssessmentRepository"; +import { ConventionRepository } from "../../convention/ports/ConventionRepository"; +import { createTransactionalUseCase } from "../../core/UseCase"; +import { + NotificationContentAndFollowedIds, + SaveNotificationAndRelatedEvent, +} from "../../core/notifications/helpers/Notification"; +import { TimeGateway } from "../../core/time-gateway/ports/TimeGateway"; +import { UnitOfWork } from "../../core/unit-of-work/ports/UnitOfWork"; + +type AssessmentReminderOutput = { + numberOfReminders: number; +}; + +export type AssessmentReminderMode = + | "3daysAfterConventionEnd" + | "10daysAfterConventionEnd"; + +export type AssessmentReminder = ReturnType; +export const makeAssessmentReminder = createTransactionalUseCase< + { mode: AssessmentReminderMode }, + AssessmentReminderOutput, + void, + { + timeGateway: TimeGateway; + saveNotificationAndRelatedEvent: SaveNotificationAndRelatedEvent; + generateConventionMagicLinkUrl: GenerateConventionMagicLinkUrl; + } +>( + { + name: "AssessmentReminder", + inputSchema: z.object({ + mode: z.enum(["3daysAfterConventionEnd", "10daysAfterConventionEnd"]), + }), + }, + async ({ inputParams: params, uow, deps }) => { + const now = deps.timeGateway.now(); + const conventionIdsToRemind = await getConventionIdsToRemind({ + mode: params.mode, + now, + assessmentRepository: uow.assessmentRepository, + conventionRepository: uow.conventionRepository, + }); + + await executeInSequence(conventionIdsToRemind, async (conventionId) => { + const convention = + await uow.conventionQueries.getConventionById(conventionId); + if (!convention) + throw errors.convention.notFound({ conventionId: conventionId }); + await sendAssessmentReminders({ + uow, + mode: params.mode, + recipientEmails: convention.agencyValidatorEmails, + convention, + now, + generateConventionMagicLinkUrl: deps.generateConventionMagicLinkUrl, + saveNotificationAndRelatedEvent: deps.saveNotificationAndRelatedEvent, + role: "validator", + }); + await sendAssessmentReminders({ + uow, + mode: params.mode, + recipientEmails: convention.agencyCounsellorEmails, + convention, + now, + generateConventionMagicLinkUrl: deps.generateConventionMagicLinkUrl, + saveNotificationAndRelatedEvent: deps.saveNotificationAndRelatedEvent, + role: "counsellor", + }); + }); + + return { + numberOfReminders: conventionIdsToRemind.length, + }; + }, +); + +const getConventionIdsToRemind = async ({ + mode, + now, + conventionRepository, + assessmentRepository, +}: { + mode: AssessmentReminderMode; + now: Date; + conventionRepository: ConventionRepository; + assessmentRepository: AssessmentRepository; +}): Promise => { + const daysAfterLastNotifications = + mode === "3daysAfterConventionEnd" ? 3 : 10; + const potentialConventionsToRemind = + await conventionRepository.getIdsValidatedByEndDateAround( + subDays(now, daysAfterLastNotifications), + ); + + const conventionsWithAssessments = ( + await assessmentRepository.getByConventionIds(potentialConventionsToRemind) + ).map((assessment) => assessment.conventionId); + + return difference(potentialConventionsToRemind, conventionsWithAssessments); +}; + +const createNotification = ({ + mode, + convention, + recipientEmail, + establishmentContactEmail, + assessmentCreationLink, +}: { + mode: AssessmentReminderMode; + convention: ConventionReadDto; + recipientEmail: Email; + establishmentContactEmail: Email; + assessmentCreationLink: AbsoluteUrl; +}): NotificationContentAndFollowedIds => { + return { + followedIds: { + agencyId: convention.agencyId, + conventionId: convention.id, + establishmentSiret: convention.siret, + }, + kind: "email", + templatedContent: { + kind: + mode === "3daysAfterConventionEnd" + ? "ASSESSMENT_AGENCY_FIRST_REMINDER" + : "ASSESSMENT_AGENCY_SECOND_REMINDER", + params: { + beneficiaryFirstName: convention.signatories.beneficiary.firstName, + beneficiaryLastName: convention.signatories.beneficiary.lastName, + conventionId: convention.id, + internshipKind: convention.internshipKind, + businessName: convention.businessName, + assessmentCreationLink, + establishmentContactEmail, + }, + recipients: [recipientEmail], + sender: immersionFacileNoReplyEmailSender, + }, + }; +}; + +const sendAssessmentReminders = async ({ + uow, + mode, + saveNotificationAndRelatedEvent, + convention, + recipientEmails, + now, + generateConventionMagicLinkUrl, + role, +}: { + uow: UnitOfWork; + mode: AssessmentReminderMode; + saveNotificationAndRelatedEvent: SaveNotificationAndRelatedEvent; + convention: ConventionReadDto; + recipientEmails: Email[]; + now: Date; + generateConventionMagicLinkUrl: GenerateConventionMagicLinkUrl; + role: AgencyRole; +}) => { + for (const recipientEmail of recipientEmails) { + const assessmentCreationLink = generateConventionMagicLinkUrl({ + id: convention.id, + email: recipientEmail, + role, + targetRoute: frontRoutes.assessment, + now, + }); + const notification = createNotification({ + mode, + convention, + recipientEmail: recipientEmail, + establishmentContactEmail: + convention.signatories.establishmentRepresentative.email, + assessmentCreationLink, + }); + await saveNotificationAndRelatedEvent(uow, notification); + } +}; diff --git a/back/src/domains/establishment/use-cases/AssessmentReminder.unit.test.ts b/back/src/domains/establishment/use-cases/AssessmentReminder.unit.test.ts new file mode 100644 index 0000000000..2ab575c978 --- /dev/null +++ b/back/src/domains/establishment/use-cases/AssessmentReminder.unit.test.ts @@ -0,0 +1,175 @@ +import subDays from "date-fns/subDays"; +import { + AgencyDtoBuilder, + ConventionDtoBuilder, + InclusionConnectedUserBuilder, + expectObjectInArrayToMatch, + frontRoutes, +} from "shared"; +import { toAgencyWithRights } from "../../../utils/agency"; +import { fakeGenerateMagicLinkUrlFn } from "../../../utils/jwtTestHelper"; +import { + SaveNotificationAndRelatedEvent, + makeSaveNotificationAndRelatedEvent, +} from "../../core/notifications/helpers/Notification"; +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 { + InMemoryUnitOfWork, + createInMemoryUow, +} from "../../core/unit-of-work/adapters/createInMemoryUow"; +import { UuidV4Generator } from "../../core/uuid-generator/adapters/UuidGeneratorImplementations"; +import { + AssessmentReminder, + makeAssessmentReminder, +} from "./AssessmentReminder"; + +describe("AssessmentReminder", () => { + let uow: InMemoryUnitOfWork; + let assessmentReminder: AssessmentReminder; + let timeGateway: TimeGateway; + let saveNotificationAndRelatedEvent: SaveNotificationAndRelatedEvent; + + beforeEach(() => { + uow = createInMemoryUow(); + timeGateway = new CustomTimeGateway(); + const uowPerformer = new InMemoryUowPerformer(uow); + saveNotificationAndRelatedEvent = makeSaveNotificationAndRelatedEvent( + new UuidV4Generator(), + timeGateway, + ); + assessmentReminder = makeAssessmentReminder({ + uowPerformer, + deps: { + timeGateway, + saveNotificationAndRelatedEvent, + generateConventionMagicLinkUrl: fakeGenerateMagicLinkUrlFn, + }, + }); + }); + + it("send first assessment reminder", async () => { + const now = timeGateway.now(); + const conventionEndDate = subDays(now, 3); + const agency = new AgencyDtoBuilder().build(); + const convention = new ConventionDtoBuilder() + .withStatus("ACCEPTED_BY_VALIDATOR") + .withDateEnd(conventionEndDate.toISOString()) + .withAgencyId(agency.id) + .build(); + const validator = new InclusionConnectedUserBuilder() + .withId("10000000-0000-0000-0000-000000000003") + .withEmail("validator@agency1.fr") + .buildUser(); + await uow.userRepository.save(validator); + uow.agencyRepository.agencies = [ + toAgencyWithRights(agency, { + [validator.id]: { + isNotifiedByEmail: true, + roles: ["validator"], + }, + }), + ]; + await uow.conventionRepository.save(convention); + + const { numberOfReminders } = await assessmentReminder.execute({ + mode: "3daysAfterConventionEnd", + }); + + expect(numberOfReminders).toBe(1); + expectObjectInArrayToMatch(uow.notificationRepository.notifications, [ + { + templatedContent: { + kind: "ASSESSMENT_AGENCY_FIRST_REMINDER", + params: { + conventionId: convention.id, + internshipKind: convention.internshipKind, + businessName: convention.businessName, + establishmentContactEmail: + convention.signatories.establishmentRepresentative.email, + beneficiaryFirstName: convention.signatories.beneficiary.firstName, + beneficiaryLastName: convention.signatories.beneficiary.lastName, + assessmentCreationLink: fakeGenerateMagicLinkUrlFn({ + id: convention.id, + email: validator.email, + role: "validator", + targetRoute: frontRoutes.assessment, + now, + }), + }, + recipients: [validator.email], + sender: { + email: "ne-pas-ecrire-a-cet-email@immersion-facile.beta.gouv.fr", + name: "Immersion Facilitée", + }, + }, + }, + ]); + expectObjectInArrayToMatch(uow.outboxRepository.events, [ + { topic: "NotificationAdded" }, + ]); + }); + + it("send second assessment reminder", async () => { + const now = timeGateway.now(); + const conventionEndDate = subDays(now, 10); + const agency = new AgencyDtoBuilder().build(); + const convention = new ConventionDtoBuilder() + .withStatus("ACCEPTED_BY_VALIDATOR") + .withDateEnd(conventionEndDate.toISOString()) + .withAgencyId(agency.id) + .build(); + const validator = new InclusionConnectedUserBuilder() + .withId("10000000-0000-0000-0000-000000000003") + .withEmail("validator@agency1.fr") + .buildUser(); + await uow.userRepository.save(validator); + uow.agencyRepository.agencies = [ + toAgencyWithRights(agency, { + [validator.id]: { + isNotifiedByEmail: true, + roles: ["validator"], + }, + }), + ]; + await uow.conventionRepository.save(convention); + + const { numberOfReminders } = await assessmentReminder.execute({ + mode: "10daysAfterConventionEnd", + }); + + expect(numberOfReminders).toBe(1); + expectObjectInArrayToMatch(uow.notificationRepository.notifications, [ + { + templatedContent: { + kind: "ASSESSMENT_AGENCY_SECOND_REMINDER", + params: { + conventionId: convention.id, + internshipKind: convention.internshipKind, + businessName: convention.businessName, + establishmentContactEmail: + convention.signatories.establishmentRepresentative.email, + beneficiaryFirstName: convention.signatories.beneficiary.firstName, + beneficiaryLastName: convention.signatories.beneficiary.lastName, + assessmentCreationLink: fakeGenerateMagicLinkUrlFn({ + id: convention.id, + email: validator.email, + role: "validator", + targetRoute: frontRoutes.assessment, + now, + }), + }, + recipients: [validator.email], + sender: { + email: "ne-pas-ecrire-a-cet-email@immersion-facile.beta.gouv.fr", + name: "Immersion Facilitée", + }, + }, + }, + ]); + expectObjectInArrayToMatch(uow.outboxRepository.events, [ + { topic: "NotificationAdded" }, + ]); + }); +}); diff --git a/back/src/scripts/triggerAssessmentReminder.ts b/back/src/scripts/triggerAssessmentReminder.ts new file mode 100644 index 0000000000..1714154ff8 --- /dev/null +++ b/back/src/scripts/triggerAssessmentReminder.ts @@ -0,0 +1,75 @@ +import { AppConfig } from "../config/bootstrap/appConfig"; +import { createGetPgPoolFn } from "../config/bootstrap/createGateways"; +import { makeGenerateConventionMagicLinkUrl } from "../config/bootstrap/magicLinkUrl"; +import { makeGenerateJwtES256 } from "../domains/core/jwt"; +import { makeSaveNotificationAndRelatedEvent } from "../domains/core/notifications/helpers/Notification"; +import { RealTimeGateway } from "../domains/core/time-gateway/adapters/RealTimeGateway"; +import { createUowPerformer } from "../domains/core/unit-of-work/adapters/createUowPerformer"; +import { UuidV4Generator } from "../domains/core/uuid-generator/adapters/UuidGeneratorImplementations"; +import { makeAssessmentReminder } from "../domains/establishment/use-cases/AssessmentReminder"; +import { createLogger } from "../utils/logger"; +import { handleCRONScript } from "./handleCRONScript"; + +const logger = createLogger(__filename); +const config = AppConfig.createFromEnv(); + +const triggerAssessmentReminder = async () => { + logger.info({ message: "Starting to send emails with assessment reminder" }); + + const timeGateway = new RealTimeGateway(); + const generateConventionJwt = makeGenerateJwtES256<"convention">( + config.jwtPrivateKey, + 3600 * 24 * 30, + ); + + const { numberOfReminders: numberOfFirstReminders } = + await makeAssessmentReminder({ + uowPerformer: createUowPerformer(config, createGetPgPoolFn(config)) + .uowPerformer, + deps: { + timeGateway, + saveNotificationAndRelatedEvent: makeSaveNotificationAndRelatedEvent( + new UuidV4Generator(), + timeGateway, + ), + generateConventionMagicLinkUrl: makeGenerateConventionMagicLinkUrl( + config, + generateConventionJwt, + ), + }, + }).execute({ mode: "3daysAfterConventionEnd" }); + + const { numberOfReminders: numberOfSecondReminders } = + await makeAssessmentReminder({ + uowPerformer: createUowPerformer(config, createGetPgPoolFn(config)) + .uowPerformer, + deps: { + timeGateway, + saveNotificationAndRelatedEvent: makeSaveNotificationAndRelatedEvent( + new UuidV4Generator(), + timeGateway, + ), + generateConventionMagicLinkUrl: makeGenerateConventionMagicLinkUrl( + config, + generateConventionJwt, + ), + }, + }).execute({ mode: "10daysAfterConventionEnd" }); + + return { + numberOfFirstReminders, + numberOfSecondReminders, + }; +}; + +handleCRONScript( + "assessmentReminder", + config, + triggerAssessmentReminder, + ({ numberOfFirstReminders, numberOfSecondReminders }) => + [ + `Total of first reminders : ${numberOfFirstReminders}`, + `Total if second reminders: ${numberOfSecondReminders}`, + ].join("\n"), + logger, +); diff --git a/front/src/app/pages/admin/EmailPreviewTab.tsx b/front/src/app/pages/admin/EmailPreviewTab.tsx index 70964bf3ea..3e3782c3f5 100644 --- a/front/src/app/pages/admin/EmailPreviewTab.tsx +++ b/front/src/app/pages/admin/EmailPreviewTab.tsx @@ -273,6 +273,24 @@ export const defaultEmailValueByEmailKind: { businessName: "BUSINESS_NAME", internshipKind: "immersion", }, + ASSESSMENT_AGENCY_FIRST_REMINDER: { + assessmentCreationLink: "ASSESSMENT_CREATION_LINK", + beneficiaryFirstName: "BENEFICIARY_FIRST_NAME", + beneficiaryLastName: "BENEFICIARY_LAST_NAME", + businessName: "BUSINESS_NAME", + conventionId: "CONVENTION_ID", + establishmentContactEmail: "ESTABLISHMENT_CONTACT_EMAIL", + internshipKind: "immersion", + }, + ASSESSMENT_AGENCY_SECOND_REMINDER: { + assessmentCreationLink: "ASSESSMENT_CREATION_LINK", + beneficiaryFirstName: "BENEFICIARY_FIRST_NAME", + beneficiaryLastName: "BENEFICIARY_LAST_NAME", + businessName: "BUSINESS_NAME", + conventionId: "CONVENTION_ID", + establishmentContactEmail: "ESTABLISHMENT_CONTACT_EMAIL", + internshipKind: "immersion", + }, ASSESSMENT_AGENCY_NOTIFICATION: { agencyLogoUrl: defaultEmailPreviewUrl, assessmentCreationLink: "ASSESSMENT_CREATION_LINK", diff --git a/shared/src/email/EmailParamsByEmailType.ts b/shared/src/email/EmailParamsByEmailType.ts index b863e094c4..393e2f9a5c 100644 --- a/shared/src/email/EmailParamsByEmailType.ts +++ b/shared/src/email/EmailParamsByEmailType.ts @@ -79,6 +79,24 @@ export type EmailParamsByEmailType = { conventionId: ConventionId; internshipKind: InternshipKind; }; + ASSESSMENT_AGENCY_FIRST_REMINDER: { + assessmentCreationLink: string; + beneficiaryFirstName: string; + beneficiaryLastName: string; + businessName: string; + conventionId: ConventionId; + establishmentContactEmail: Email; + internshipKind: InternshipKind; + }; + ASSESSMENT_AGENCY_SECOND_REMINDER: { + assessmentCreationLink: string; + beneficiaryFirstName: string; + beneficiaryLastName: string; + businessName: string; + conventionId: ConventionId; + establishmentContactEmail: Email; + internshipKind: InternshipKind; + }; ASSESSMENT_BENEFICIARY_NOTIFICATION: { conventionId: ConventionId; beneficiaryFirstName: string; diff --git a/shared/src/email/emailTemplatesByName.ts b/shared/src/email/emailTemplatesByName.ts index 24523c7b9e..9050135e08 100644 --- a/shared/src/email/emailTemplatesByName.ts +++ b/shared/src/email/emailTemplatesByName.ts @@ -35,6 +35,72 @@ const createConventionStatusButton = (link: string): EmailButtonProps => ({ // to add a new EmailType, or changes the params of one, edit first EmailParamsByEmailType and let types guide you export const emailTemplatesByName = createTemplatesByName({ + ASSESSMENT_AGENCY_FIRST_REMINDER: { + niceName: + "Bilan - Prescripteurs - Relance à 3 jours après la fin de l’immersion", + tags: ["bilan_prescripteur_formulaireBilan_J+3"], + createEmailVariables: ({ + assessmentCreationLink, + beneficiaryFirstName, + beneficiaryLastName, + businessName, + conventionId, + establishmentContactEmail, + internshipKind, + }) => ({ + subject: `Immersion Facilitée - Bilan non complété pour l'immersion de ${beneficiaryFirstName}`, + greetings: greetingsWithConventionId(conventionId), + content: ` + Nous constatons que le bilan de l’immersion de ${beneficiaryFirstName} ${beneficiaryLastName} n’a pas encore été complété par l’entreprise ${businessName}. + + Afin de clôturer cette étape, vous pouvez : + + 1. Relancer directement l'entreprise (${establishmentContactEmail}) pour qu’elle remplisse le bilan en ligne. + 2. Les contacter par téléphone pour les accompagner dans la saisie du bilan. + `, + buttons: [ + { + label: "Formulaire de bilan", + url: assessmentCreationLink, + }, + ], + subContent: ` + ${defaultSignature(internshipKind)} + `, + }), + }, + ASSESSMENT_AGENCY_SECOND_REMINDER: { + niceName: + "Bilan - Prescripteurs - Relance à 10 jours après la fin de l’immersion", + tags: ["bilan_prescripteur_formulaireBilan_J+10"], + createEmailVariables: ({ + assessmentCreationLink, + beneficiaryFirstName, + beneficiaryLastName, + businessName, + conventionId, + establishmentContactEmail, + internshipKind, + }) => ({ + subject: `Immersion Facilitée - Urgent : Bilan toujours non complété pour l'immersion de ${beneficiaryFirstName}`, + greetings: greetingsWithConventionId(conventionId), + content: ` + Malgré une première relance, le bilan de l’immersion de ${beneficiaryFirstName} ${beneficiaryLastName} chez ${businessName} reste incomplet. + + 1. Relancer l'entreprise (${establishmentContactEmail}) pour qu’elle remplisse le bilan. + 2. Les contacter par téléphone et les accompagner pour le compléter ensemble. + `, + buttons: [ + { + label: "Formulaire de bilan", + url: assessmentCreationLink, + }, + ], + subContent: ` + ${defaultSignature(internshipKind)} + `, + }), + }, ASSESSMENT_AGENCY_NOTIFICATION: { niceName: "Bilan - Prescripteurs - Lien de création du bilan", tags: ["bilan_prescripteur_formulaireBilan"], @@ -65,7 +131,7 @@ export const emailTemplatesByName = }, ], subContent: ` - Ces informations sont importantes pour la suite du parcours professionnel de BENEFICIARY_FIRST_NAME BENEFICIARY_LAST_NAME. + Ces informations sont importantes pour la suite du parcours professionnel de ${beneficiaryFirstName} ${beneficiaryLastName}. ${defaultSignature(internshipKind)} `, agencyLogoUrl,