diff --git a/.talismanrc b/.talismanrc index b0c2af571..83423b9b5 100644 --- a/.talismanrc +++ b/.talismanrc @@ -34,7 +34,7 @@ fileignoreconfig: - filename: server/src/http/controllers/user.controller.test.ts checksum: e01d1b9c54d1e6d0c98fd7984f32c3a93f3e9bde92887bdab320cb7af91fdd92 - filename: server/src/http/controllers/v2/applications.controller.v2.test.ts - checksum: 9147c705b5bdfe6eff6b1b75c467249ee1af783d57ff16d78722e48e60831c68 + checksum: 2cba54edcbde1d849514fc4f9de14741c238a6c31aa012664e37835d20fa8cd9 - filename: server/src/http/controllers/v2/appointment.controller.v2.test.ts checksum: 52a0f730bc8feed13f4f1371c16a332872bec77ca31f0732422b7ca90b322df9 - filename: server/src/http/controllers/v2/jobs.controller.v2.test.ts diff --git a/server/src/http/controllers/application.controller.test.ts b/server/src/http/controllers/application.controller.test.ts index 8d2c99997..1d4d95f9a 100644 --- a/server/src/http/controllers/application.controller.test.ts +++ b/server/src/http/controllers/application.controller.test.ts @@ -100,6 +100,7 @@ describe("POST /v1/application", () => { company_address: "126 RUE DE L UNIVERSITE, 75007 Paris", company_email: recruteur.email, company_feedback: null, + company_feedback_reasons: null, company_naf: "Administration publique générale", company_name: "ASSEMBLEE NATIONALE", company_phone: null, diff --git a/server/src/http/controllers/application.controller.ts b/server/src/http/controllers/application.controller.ts index a7cd32e8f..c7156f4ba 100644 --- a/server/src/http/controllers/application.controller.ts +++ b/server/src/http/controllers/application.controller.ts @@ -1,11 +1,9 @@ -import { notFound } from "@hapi/boom" import { ObjectId } from "mongodb" import { oldItemTypeToNewItemType } from "shared/constants/lbaitem" import { zRoutes } from "shared/index" import { getDbCollection } from "../../common/utils/mongodbUtils" -import { getApplicantFromDB } from "../../services/applicant.service" -import { getCompanyEmailFromToken, sendApplication, sendMailToApplicant } from "../../services/application.service" +import { getApplicationDataForIntentionAndScheduleMessage, getCompanyEmailFromToken, sendApplication, sendRecruiterIntention } from "../../services/application.service" import { Server } from "../server" const rateLimitConfig = { @@ -59,36 +57,37 @@ export default function (server: Server) { }, async (req, res) => { const { id } = req.params - const { company_recruitment_intention, company_feedback, email, phone } = req.body + const { company_recruitment_intention, company_feedback, email, phone, refusal_reasons } = req.body - const application = await getDbCollection("applications").findOneAndUpdate( - { _id: new ObjectId(id) }, - { $set: { company_recruitment_intention, company_feedback, company_feedback_date: new Date() } } - ) - - if (!application) { - throw notFound() - } - - const applicant = await getApplicantFromDB({ _id: application.applicant_id }) - - if (!applicant) { - throw notFound(`unexpected: applicant not found for application ${application._id}`) - } - - await sendMailToApplicant({ - application, - applicant, - email, - phone, + await sendRecruiterIntention({ + application_id: new ObjectId(id), company_recruitment_intention, company_feedback, + email, + phone, + refusal_reasons, }) return res.status(200).send({ result: "ok", message: "comment registered" }) } ) + server.post( + "/application/intention/cancel/:id", + { + schema: zRoutes.post["/application/intention/cancel/:id"], + onRequest: server.auth(zRoutes.post["/application/intention/cancel/:id"]), + config: rateLimitConfig, + }, + async (req, res) => { + const { id } = req.params + + await getDbCollection("recruiter_intention_mails").deleteOne({ applicationId: new ObjectId(id) }) + + return res.status(200).send({ result: "ok", message: "intention canceled" }) + } + ) + server.post( "/application/intention/:id", { @@ -121,4 +120,18 @@ export default function (server: Server) { return res.status(200).send({ company_email }) } ) + + server.get( + "/application/intention/schedule/:id", + { + schema: zRoutes.get["/application/intention/schedule/:id"], + onRequest: server.auth(zRoutes.get["/application/intention/schedule/:id"]), + }, + async (req, res) => { + const { id } = req.params + const { intention } = req.query + const data = await getApplicationDataForIntentionAndScheduleMessage(id, intention) + return res.status(200).send({ ...data }) + } + ) } diff --git a/server/src/http/controllers/v2/applications.controller.v2.test.ts b/server/src/http/controllers/v2/applications.controller.v2.test.ts index 31d128ba2..218edf687 100644 --- a/server/src/http/controllers/v2/applications.controller.v2.test.ts +++ b/server/src/http/controllers/v2/applications.controller.v2.test.ts @@ -1,16 +1,20 @@ import { ObjectId } from "mongodb" import { IApplicationApiPublic, JOB_STATUS } from "shared" import { NIVEAUX_POUR_LBA, RECRUITER_STATUS } from "shared/constants" +import { ApplicationIntention } from "shared/constants/application" import { LBA_ITEM_TYPE } from "shared/constants/lbaitem" -import { applicationTestFile, wrongApplicationTestFile } from "shared/fixtures/application.fixture" +import { applicationTestFile, generateApplicantFixture, generateApplicationFixture, wrongApplicationTestFile } from "shared/fixtures/application.fixture" import { generateRecruiterFixture } from "shared/fixtures/recruiter.fixture" import { generateLbaCompanyFixture } from "shared/fixtures/recruteurLba.fixture" import { parisFixture } from "shared/fixtures/referentiel/commune.fixture" import { generateReferentielRome } from "shared/fixtures/rome.fixture" +import { generateUserWithAccountFixture } from "shared/fixtures/userWithAccount.fixture" import { describe, expect, it, vi } from "vitest" import { s3Write } from "@/common/utils/awsUtils" import { getDbCollection } from "@/common/utils/mongodbUtils" +import { buildUserForToken } from "@/services/application.service" +import { generateApplicationReplyToken } from "@/services/appLinks.service" import { useMongo } from "@tests/utils/mongo.test.utils" import { useServer } from "@tests/utils/server.test.utils" @@ -63,12 +67,19 @@ const recruteur = generateLbaCompanyFixture({ last_update_at: new Date("2024-07-04T23:24:58.995Z"), }) +const recruiterEmailFixture = "test-application@mail.fr" + +const user = generateUserWithAccountFixture({ + _id: new ObjectId("670ce1ded6ce30c3c90a0e1d"), + email: recruiterEmailFixture, +}) + const recruiter = generateRecruiterFixture({ establishment_siret: "11000001500013", establishment_raison_sociale: "ASSEMBLEE NATIONALE", geopoint: parisFixture.centre, status: RECRUITER_STATUS.ACTIF, - email: "test-application@mail.fr", + email: recruiterEmailFixture, jobs: [ { rome_code: ["M1602"], @@ -78,6 +89,15 @@ const recruiter = generateRecruiterFixture({ job_creation_date: new Date("2021-01-01"), job_expiration_date: new Date("2050-01-01"), }, + { + _id: new ObjectId("64a43d28eeeb7c3b210faf59"), + rome_code: ["M1602"], + rome_label: "Opérations administratives", + job_status: JOB_STATUS.ACTIVE, + job_level_label: NIVEAUX_POUR_LBA.INDIFFERENT, + job_creation_date: new Date("2021-01-01"), + job_expiration_date: new Date("2050-01-01"), + }, ], address_detail: { code_insee_localite: parisFixture.code, @@ -94,10 +114,24 @@ const referentielRome = generateReferentielRome({ }, }) +const applicantFixture = generateApplicantFixture({}) + +const applicationFixture = generateApplicationFixture({ + _id: new ObjectId("6081289803569600282e0001"), + job_id: "64a43d28eeeb7c3b210faf59", + job_origin: LBA_ITEM_TYPE.OFFRES_EMPLOI_LBA, + applicant_id: applicantFixture._id, +}) + +const userToken = buildUserForToken(applicationFixture, user) +const intentionToken = generateApplicationReplyToken(userToken, applicationFixture._id.toString(), ApplicationIntention.ENTRETIEN) + const mockData = async () => { await getDbCollection("recruteurslba").insertOne(recruteur) await getDbCollection("recruiters").insertOne(recruiter) await getDbCollection("referentielromes").insertOne(referentielRome) + await getDbCollection("applicants").insertOne(applicantFixture) + await getDbCollection("applications").insertOne(applicationFixture) } useMongo(mockData) @@ -152,6 +186,7 @@ describe("POST /v2/application", () => { applicant_phone: body.applicant_phone, company_email: recruteur.email, company_feedback: null, + company_feedback_reasons: null, company_name: "ASSEMBLEE NATIONALE", company_phone: null, company_recruitment_intention: null, @@ -212,6 +247,7 @@ describe("POST /v2/application", () => { company_address: "Paris", company_email: "test-application@mail.fr", company_feedback: null, + company_feedback_reasons: null, company_naf: "", company_name: "ASSEMBLEE NATIONALE", company_phone: "0300000000", @@ -254,4 +290,89 @@ describe("POST /v2/application", () => { expect.soft(response.statusCode).toEqual(400) expect.soft(response.json()).toEqual({ statusCode: 400, error: "Bad Request", message: "File type is not supported" }) }) + + it("save scheduled intention when link in email is followed", async () => { + const response = await httpClient().inject({ + method: "GET", + path: `/api/application/intention/schedule/6081289803569600282e0001?intention=${ApplicationIntention.ENTRETIEN}`, + headers: { authorization: `Bearer ${intentionToken}` }, + }) + + expect.soft(response.statusCode).toEqual(200) + expect + .soft(response.json()) + .toEqual({ applicant_first_name: "a", applicant_last_name: "a", recruiter_email: "faux_email@faux-domaine-compagnie.com", recruiter_phone: "0300000000" }) + const intentionInDb = await getDbCollection("recruiter_intention_mails").findOne({ applicationId: new ObjectId("6081289803569600282e0001") }) + expect.soft(intentionInDb).not.toEqual(null) + }) + + it("Remove scheduled intention when cancel button", async () => { + await httpClient().inject({ + method: "GET", + path: `/api/application/intention/schedule/6081289803569600282e0001?intention=${ApplicationIntention.ENTRETIEN}`, + headers: { authorization: `Bearer ${intentionToken}` }, + }) + + const response = await httpClient().inject({ + method: "POST", + path: "/api/application/intention/cancel/6081289803569600282e0001", + headers: { authorization: `Bearer ${intentionToken}` }, + }) + + expect.soft(response.statusCode).toEqual(200) + expect.soft(response.json()).toEqual({ result: "ok", message: "intention canceled" }) + const intentionInDb = await getDbCollection("recruiter_intention_mails").findOne({ applicationId: new ObjectId("6081289803569600282e0001") }) + expect.soft(intentionInDb).toEqual(null) + }) + + it("Remove scheduled intention when cancel button", async () => { + await httpClient().inject({ + method: "GET", + path: `/api/application/intention/schedule/6081289803569600282e0001?intention=${ApplicationIntention.ENTRETIEN}`, + headers: { authorization: `Bearer ${intentionToken}` }, + }) + + const response = await httpClient().inject({ + method: "POST", + path: "/api/application/intention/cancel/6081289803569600282e0001", + headers: { authorization: `Bearer ${intentionToken}` }, + }) + + expect.soft(response.statusCode).toEqual(200) + expect.soft(response.json()).toEqual({ result: "ok", message: "intention canceled" }) + const intentionInDb = await getDbCollection("recruiter_intention_mails").findOne({ applicationId: new ObjectId("6081289803569600282e0001") }) + expect.soft(intentionInDb).toEqual(null) + }) + + it.skip("Remove scheduled intention when Envoyer le message button", async () => { + await httpClient().inject({ + method: "GET", + path: `/api/application/intention/schedule/6081289803569600282e0001?intention=${ApplicationIntention.ENTRETIEN}`, + headers: { authorization: `Bearer ${intentionToken}` }, + }) + + const response = await httpClient().inject({ + method: "POST", + path: "/api/application/intentionComment/6081289803569600282e0001", + body: { + company_feedback: "Bonjour", + company_recruitment_intention: ApplicationIntention.ENTRETIEN, + email: "faux_email@faux-domaine-compagnie.com", + phone: "", + refusal_reasons: [], + }, + headers: { authorization: `Bearer ${intentionToken}` }, + }) + + expect.soft(response.statusCode).toEqual(200) + expect.soft(response.json()).toEqual({ result: "ok", message: "comment registered" }) + const intentionInDb = await getDbCollection("recruiter_intention_mails").findOne({ applicationId: new ObjectId("6081289803569600282e0001") }) + expect.soft(intentionInDb).toEqual(null) + const application = await getDbCollection("applications").findOne({ _id: new ObjectId("6081289803569600282e0001") }) + expect.soft(application!).toMatchObject({ + company_feedback_reasons: [], + company_feedback: "Bonjour", + company_recruitment_intention: ApplicationIntention.ENTRETIEN, + }) + }) }) diff --git a/server/src/jobs/applications/processRecruiterIntentions.ts b/server/src/jobs/applications/processRecruiterIntentions.ts new file mode 100644 index 000000000..627776c35 --- /dev/null +++ b/server/src/jobs/applications/processRecruiterIntentions.ts @@ -0,0 +1,5 @@ +import { processScheduledRecruiterIntentions } from "@/services/application.service" + +export const processRecruiterIntentions = async () => { + await processScheduledRecruiterIntentions() +} diff --git a/server/src/jobs/jobs.ts b/server/src/jobs/jobs.ts index dc695d3d5..dc3cf643c 100644 --- a/server/src/jobs/jobs.ts +++ b/server/src/jobs/jobs.ts @@ -16,6 +16,7 @@ import anonymizeIndividual from "./anonymization/anonymizeIndividual" import { anonimizeUsersWithAccounts } from "./anonymization/anonymizeUserRecruteurs" import { anonymizeUsers } from "./anonymization/anonymizeUsers" import { processApplications } from "./applications/processApplications" +import { processRecruiterIntentions } from "./applications/processRecruiterIntentions" import { sendContactsToBrevo } from "./brevoContacts/sendContactsToBrevo" import { recreateIndexes } from "./database/recreateIndexes" import { validateModels } from "./database/schemaValidation" @@ -232,6 +233,10 @@ export async function setupJobProcessor() { cron_string: "40 3 * * *", handler: processJobPartners, }, + "Emission des intentions des recruteurs": { + cron_string: "30 20 * * *", + handler: processRecruiterIntentions, + }, }, jobs: { "remove:duplicates:recruiters": { diff --git a/server/src/jobs/simpleJobDefinitions.ts b/server/src/jobs/simpleJobDefinitions.ts index 5602764d0..0c9bf5b3d 100644 --- a/server/src/jobs/simpleJobDefinitions.ts +++ b/server/src/jobs/simpleJobDefinitions.ts @@ -1,3 +1,4 @@ +import { processScheduledRecruiterIntentions } from "@/services/application.service" import { generateSitemap } from "@/services/sitemap.service" import { anonymizeApplicantsAndApplications } from "./anonymization/anonymizeApplicantAndApplications" @@ -216,6 +217,10 @@ export const simpleJobDefinitions: SimpleJobDefinition[] = [ fct: processJobPartners, description: "Chaîne complète de traitement des jobs_partners", }, + { + fct: processScheduledRecruiterIntentions, + description: "Envoi les intentations des recruteurs programmées", + }, { fct: resetInvitationDates, description: "Permet de réinitialiser les dates d'invitation et de refus des établissements pour la prise de rendez-vous", diff --git a/server/src/services/appLinks.service.ts b/server/src/services/appLinks.service.ts index 6207e86eb..8581815c1 100644 --- a/server/src/services/appLinks.service.ts +++ b/server/src/services/appLinks.service.ts @@ -1,3 +1,4 @@ +import { ApplicationIntention } from "shared/constants/application" import { IJob } from "shared/models" import { IUserWithAccount } from "shared/models/userWithAccount.model" import { zRoutes } from "shared/routes" @@ -308,7 +309,7 @@ export function createRdvaShortRecapToken(email: string, appointmentId: string) return token } -export function generateApplicationReplyToken(tokenUser: UserForAccessToken, applicationId: string) { +export function generateApplicationReplyToken(tokenUser: UserForAccessToken, applicationId: string, intention: ApplicationIntention) { return generateAccessToken( tokenUser, [ @@ -326,6 +327,20 @@ export function generateApplicationReplyToken(tokenUser: UserForAccessToken, app querystring: undefined, }, }), + generateScope({ + schema: zRoutes.post["/application/intention/cancel/:id"], + options: { + params: { id: applicationId }, + querystring: undefined, + }, + }), + generateScope({ + schema: zRoutes.get["/application/intention/schedule/:id"], + options: { + params: { id: applicationId }, + querystring: { intention }, + }, + }), ], { expiresIn: "30d", diff --git a/server/src/services/application.service.ts b/server/src/services/application.service.ts index 444bea19b..5015c75ed 100644 --- a/server/src/services/application.service.ts +++ b/server/src/services/application.service.ts @@ -1,3 +1,6 @@ +import { Transform } from "stream" +import { pipeline } from "stream/promises" + import { badRequest, internal, notFound, tooManyRequests } from "@hapi/boom" import { isEmailBurner } from "burner-email-providers" import dayjs from "dayjs" @@ -5,6 +8,7 @@ import { fileTypeFromBuffer } from "file-type" import { ObjectId } from "mongodb" import { ApplicationScanStatus, + EMAIL_LOG_TYPE, IApplicant, IApplication, IApplicationApiPrivateOutput, @@ -16,12 +20,13 @@ import { JOB_STATUS, assertUnreachable, } from "shared" -import { ApplicantIntention } from "shared/constants/application" +import { ApplicationIntention, ApplicationIntentionDefaultText, RefusalReasons } from "shared/constants/application" import { BusinessErrorCodes } from "shared/constants/errorCodes" import { LBA_ITEM_TYPE, getDirectJobPath, newItemTypeToOldItemType } from "shared/constants/lbaitem" import { CFA, ENTREPRISE, RECRUITER_STATUS } from "shared/constants/recruteur" import { prepareMessageForMail, removeUrlsFromText } from "shared/helpers/common" import { IJobsPartnersOfferPrivate } from "shared/models/jobsPartners.model" +import { IRecruiterIntentionMail } from "shared/models/recruiterIntentionMail.model" import { ITrackingCookies } from "shared/models/trafficSources.model" import { IUserWithAccount } from "shared/models/userWithAccount.model" import { z } from "zod" @@ -30,6 +35,7 @@ import { s3Delete, s3ReadAsString, s3Write } from "@/common/utils/awsUtils" import { getStaticFilePath } from "@/common/utils/getStaticFilePath" import { createToken, getTokenValue } from "@/common/utils/jwtUtils" import { getDbCollection } from "@/common/utils/mongodbUtils" +import { notifyToSlack } from "@/common/utils/slackUtils" import { UserForAccessToken, userWithAccountToUserForToken } from "@/security/accessTokenService" import { logger } from "../common/logger" @@ -365,7 +371,7 @@ const buildUrlsOfDetail = (publicUrl: string, application: IApplication, utm?: { } } -const buildUserForToken = (application: IApplication, user?: IUserWithAccount): UserForAccessToken => { +export const buildUserForToken = (application: IApplication, user?: IUserWithAccount): UserForAccessToken => { const { job_origin, company_siret, company_email } = application if (job_origin === LBA_ITEM_TYPE.RECRUTEURS_LBA) { return { type: "lba-company", siret: company_siret, email: company_email } @@ -380,14 +386,12 @@ const buildUserForToken = (application: IApplication, user?: IUserWithAccount): } // get data from applicant -const buildReplyLink = (application: IApplication, applicant: IApplicant, intention: ApplicantIntention, userForToken: UserForAccessToken) => { +const buildReplyLink = (application: IApplication, applicant: IApplicant, intention: ApplicationIntention, userForToken: UserForAccessToken) => { const type = application.job_id ? LBA_ITEM_TYPE.OFFRES_EMPLOI_LBA : LBA_ITEM_TYPE.RECRUTEURS_LBA const applicationId = application._id.toString() const searchParams = new URLSearchParams() searchParams.append("company_recruitment_intention", intention) searchParams.append("id", applicationId) - searchParams.append("fn", applicant.firstname) - searchParams.append("ln", applicant.lastname) searchParams.append("utm_source", "lba") searchParams.append("utm_medium", "email") if (type === LBA_ITEM_TYPE.OFFRES_EMPLOI_LBA) { @@ -396,7 +400,7 @@ const buildReplyLink = (application: IApplication, applicant: IApplicant, intent if (type === LBA_ITEM_TYPE.RECRUTEURS_LBA) { searchParams.append("utm_campaign", "je-candidate-spontanement-recruteur") } - const token = generateApplicationReplyToken(userForToken, applicationId) + const token = generateApplicationReplyToken(userForToken, applicationId, intention) searchParams.append("token", token) return `${config.publicUrl}/formulaire-intention?${searchParams.toString()}` } @@ -432,8 +436,8 @@ const buildRecruiterEmailUrls = async (application: IApplication, applicant: IAp const userForToken = buildUserForToken(application, user) const urls = { jobUrl: "", - meetCandidateUrl: buildReplyLink(application, applicant, ApplicantIntention.ENTRETIEN, userForToken), - refuseCandidateUrl: buildReplyLink(application, applicant, ApplicantIntention.REFUS, userForToken), + meetCandidateUrl: buildReplyLink(application, applicant, ApplicationIntention.ENTRETIEN, userForToken), + refuseCandidateUrl: buildReplyLink(application, applicant, ApplicationIntention.REFUS, userForToken), lbaRecruiterUrl: `${config.publicUrl}/acces-recruteur?${utmRecruiterData}-acces-recruteur`, unsubscribeUrl: `${config.publicUrl}/desinscription?application_id=${createToken({ application_id: application._id }, "30d", "desinscription")}${utmRecruiterData}-desinscription`, lbaUrl: `${config.publicUrl}?${utmRecruiterData}-home`, @@ -530,6 +534,7 @@ const newApplicationToApplicationDocument = async (newApplication: INewApplicati company_email: recruteurEmail.toLowerCase(), company_recruitment_intention: null, company_feedback: null, + company_feedback_reasons: null, job_origin: newApplication.company_type, _id: new ObjectId(), created_at: now, @@ -564,6 +569,7 @@ const newApplicationToApplicationDocumentV2 = async ( job_searched_by_user: "job_searched_by_user" in newApplication ? newApplication.job_searched_by_user : null, company_recruitment_intention: null, company_feedback: null, + company_feedback_reasons: null, caller: caller, job_origin: LbaJob.type, created_at: now, @@ -778,19 +784,21 @@ export const sendMailToApplicant = async ({ phone, company_recruitment_intention, company_feedback, + refusal_reasons, }: { application: IApplication applicant: IApplicant email: string | null phone: string | null - company_recruitment_intention: string + company_recruitment_intention: ApplicationIntention company_feedback: string + refusal_reasons: RefusalReasons[] }): Promise => { const partner = (application.caller && PARTNER_NAMES[application.caller]) ?? null const jobSourceType: string = await getJobSourceType(application) switch (company_recruitment_intention) { - case ApplicantIntention.ENTRETIEN: { + case ApplicationIntention.ENTRETIEN: { mailer.sendEmail({ to: applicant.email, cc: email!, @@ -809,7 +817,7 @@ export const sendMailToApplicant = async ({ }) break } - case ApplicantIntention.NESAISPAS: { + case ApplicationIntention.NESAISPAS: { mailer.sendEmail({ to: application.applicant_email, cc: email!, @@ -827,7 +835,7 @@ export const sendMailToApplicant = async ({ }) break } - case ApplicantIntention.REFUS: { + case ApplicationIntention.REFUS: { mailer.sendEmail({ to: application.applicant_email, subject: `Réponse négative de ${application.company_name} à la candidature${partner ? ` ${partner}` : ""} de ${applicant.firstname} ${applicant.lastname}`, @@ -839,6 +847,7 @@ export const sendMailToApplicant = async ({ partner, ...images, comment: prepareMessageForMail(sanitizeForEmail(company_feedback)), + reasons: refusal_reasons, }, }) break @@ -868,8 +877,6 @@ export interface IApplicationCount { /** * @description retourne le nombre de candidatures enregistrées par identifiant d'offres lba fournis - * @param {IJobs["_id"][]} job_ids - * @returns {Promise} token data */ export const getApplicationByJobCount = async (job_ids: IApplication["job_id"][]): Promise => { const applicationCountByJob = (await getDbCollection("applications") @@ -1065,17 +1072,16 @@ export const processApplicationEmails = { }, // get data from applicant async sendRecruteurEmail(application: IApplication, applicant: IApplicant, attachmentContent: string) { - const { job_id } = application - const type = job_id ? LBA_ITEM_TYPE.OFFRES_EMPLOI_LBA : LBA_ITEM_TYPE.RECRUTEURS_LBA + const { job_origin } = application const { url: urlOfDetail, urlWithoutUtm: urlOfDetailNoUtm } = buildUrlsOfDetail(publicUrl, application, { utm_campaign: "je-candidate-recruteur" }) const recruiterEmailUrls = await buildRecruiterEmailUrls(application, applicant) const emailCompany = await mailer.sendEmail({ to: application.company_email, subject: - (type === LBA_ITEM_TYPE.RECRUTEURS_LBA ? `Candidature spontanée en alternance ${application.company_name}` : `Candidature en alternance - ${application.job_title}`) + + (job_origin === LBA_ITEM_TYPE.RECRUTEURS_LBA ? `Candidature spontanée en alternance ${application.company_name}` : `Candidature en alternance - ${application.job_title}`) + ` - ${application.applicant_first_name} ${application.applicant_last_name}`, - template: getEmailTemplate(type === LBA_ITEM_TYPE.RECRUTEURS_LBA ? "mail-candidature-spontanee" : "mail-candidature"), + template: getEmailTemplate(job_origin === LBA_ITEM_TYPE.RECRUTEURS_LBA ? "mail-candidature-spontanee" : "mail-candidature"), data: { ...sanitizeApplicationForEmail(application), ...sanitizeApplicantForEmail(applicant), @@ -1100,13 +1106,12 @@ export const processApplicationEmails = { }, // get data from applicant async sendCandidatEmail(application: IApplication, applicant: IApplicant) { - const { job_id } = application - const type = job_id ? LBA_ITEM_TYPE.OFFRES_EMPLOI_LBA : LBA_ITEM_TYPE.RECRUTEURS_LBA + const { job_origin } = application const { url: urlOfDetail, urlWithoutUtm: urlOfDetailNoUtm } = buildUrlsOfDetail(publicUrl, application) const emailCandidat = await mailer.sendEmail({ to: applicant.email, subject: `Votre candidature chez ${application.company_name}`, - template: getEmailTemplate(type === LBA_ITEM_TYPE.OFFRES_EMPLOI_LBA ? "mail-candidat-offre-emploi-lba" : "mail-candidat-recruteur-lba"), + template: getEmailTemplate(job_origin === LBA_ITEM_TYPE.OFFRES_EMPLOI_LBA ? "mail-candidat-offre-emploi-lba" : "mail-candidat-recruteur-lba"), data: { ...sanitizeApplicationForEmail(application), ...sanitizeApplicantForEmail(applicant), @@ -1194,3 +1199,173 @@ export const getCompanyEmailFromToken = async (token: string) => { throw notFound("Adresse non trouvée") } + +const getJobOrCompanyFromApplication = async (application: IApplication) => { + let recruiter: ILbaCompany | IRecruiter | null + let job: IJob | null | undefined + switch (application.job_origin) { + case LBA_ITEM_TYPE.OFFRES_EMPLOI_LBA: { + const jobId = new ObjectId(application.job_id!) + recruiter = await getDbCollection("recruiters").findOne({ "jobs._id": jobId }) + if (recruiter !== null) { + job = recruiter.jobs.find((job) => job._id === jobId) + } + + break + } + case LBA_ITEM_TYPE.RECRUTEURS_LBA: { + recruiter = await getDbCollection("recruteurslba").findOne({ siret: application.company_siret }) + break + } + default: + throw internal("type de candidature anormal") + } + + return { + type: application.job_origin, + job, + recruiter, + } as IJobOrCompany +} + +const getPhoneForApplication = async (application: IApplication) => { + const jobOrCompany = await getJobOrCompanyFromApplication(application) + if (!jobOrCompany.recruiter) throw internal(`Société pour ${application.job_origin} introuvable`) + return jobOrCompany.recruiter.phone +} + +export const getApplicationDataForIntentionAndScheduleMessage = async (application_id: string, intention: ApplicationIntention) => { + const application = await getDbCollection("applications").findOne({ _id: new ObjectId(application_id) }) + + if (!application) throw notFound("Candidature non trouvée") + + const recruiter_phone = (await getPhoneForApplication(application)) ?? "" + + await getDbCollection("recruiter_intention_mails").updateOne( + { + applicationId: application._id, + }, + { + $setOnInsert: { _id: new ObjectId(), applicationId: application._id }, + $set: { + createdAt: new Date(), + intention, + }, + }, + { upsert: true } + ) + + return { + recruiter_email: application.company_email, + recruiter_phone, + applicant_first_name: application.applicant_first_name, + applicant_last_name: application.applicant_last_name, + } +} + +export const sendRecruiterIntention = async ({ + application_id, + company_recruitment_intention, + company_feedback, + email, + phone, + refusal_reasons, + shouldComputePhoneAndEmail = false, +}: { + application_id: ObjectId + company_recruitment_intention: ApplicationIntention + company_feedback: string + email: string | null + phone: string | null + refusal_reasons: RefusalReasons[] + shouldComputePhoneAndEmail?: boolean +}) => { + const application = await getDbCollection("applications").findOneAndUpdate( + { _id: application_id }, + { $set: { company_recruitment_intention, company_feedback, company_feedback_reasons: refusal_reasons, company_feedback_date: new Date() } } + ) + + if (!application) { + throw notFound(`unexpected: application not found when processing intentions. application_id=${application_id}`) + } + + const applicant = await getApplicantFromDB({ _id: application.applicant_id }) + + if (!applicant) { + throw notFound(`unexpected: applicant not found for application ${application_id}`) + } + + const computedPhone = shouldComputePhoneAndEmail ? ((await getPhoneForApplication(application)) ?? "") : phone + const computedEmail = shouldComputePhoneAndEmail ? application.company_email : email + + await sendMailToApplicant({ + application, + applicant, + email: computedEmail, + phone: computedPhone, + company_recruitment_intention, + company_feedback, + refusal_reasons, + }) + + await getDbCollection("recruiter_intention_mails").deleteOne({ applicationId: application_id }) + + await getDbCollection("applicants_email_logs").insertOne({ + _id: new ObjectId(), + applicant_id: applicant._id, + type: company_recruitment_intention === ApplicationIntention.ENTRETIEN ? EMAIL_LOG_TYPE.INTENTION_ENTRETIEN : EMAIL_LOG_TYPE.INTENTION_REFUS, + message_id: null, + createdAt: new Date(), + }) +} + +export const processScheduledRecruiterIntentions = async () => { + try { + const stream = await getDbCollection("recruiter_intention_mails") + .find({ createdAt: { $lte: dayjs().subtract(3, "hours").toDate() } }) + .stream() + + const counters = { total: 0, entretien: 0, error: 0 } + + const transform = new Transform({ + objectMode: true, + async transform(recruiterIntention: IRecruiterIntentionMail, encoding, callback: (error?: Error | null, data?: any) => void) { + counters.total++ + try { + await sendRecruiterIntention({ + application_id: recruiterIntention.applicationId, + company_recruitment_intention: recruiterIntention.intention, + company_feedback: recruiterIntention.intention === ApplicationIntention.REFUS ? ApplicationIntentionDefaultText.REFUS : ApplicationIntentionDefaultText.ENTRETIEN, + email: null, + phone: null, + refusal_reasons: [], + shouldComputePhoneAndEmail: true, + }) + + if (recruiterIntention.intention === ApplicationIntention.ENTRETIEN) { + counters.entretien++ + } + } catch (intentionErr) { + counters.error++ + sentryCaptureException(intentionErr) + } + callback(null) + }, + }) + + await pipeline(stream, transform) + + await notifyToSlack({ + subject: "Envoi des intentions des recruteurs", + message: `${counters.total} intentions traitrées. ${counters.total - counters.error} intentions envoyées. ${counters.entretien} proposition(s) d'entretien. ${counters.error} erreurs.`, + error: false, + }) + } catch (err) { + await notifyToSlack({ + subject: "Envoi des intentions des recruteurs", + message: "Erreur technique dans le traitement des intentions des recruteurs", + error: true, + }) + throw err + } +} diff --git a/server/src/services/jobs/jobOpportunity/jobOpportunity.service.test.ts b/server/src/services/jobs/jobOpportunity/jobOpportunity.service.test.ts index 24bda3999..37384f89d 100644 --- a/server/src/services/jobs/jobOpportunity/jobOpportunity.service.test.ts +++ b/server/src/services/jobs/jobOpportunity/jobOpportunity.service.test.ts @@ -1486,9 +1486,9 @@ describe("findJobsOpportunities", () => { ) expect.soft(results.jobs).toHaveLength(3) - expect.soft(results.jobs[0].identifier.partner_label).toEqual(JOBPARTNERS_LABEL.OFFRES_EMPLOI_LBA) - expect.soft(results.jobs[1].identifier.partner_label).toEqual(JOBPARTNERS_LABEL.HELLOWORK) - expect.soft(results.jobs[2].identifier.partner_label).toEqual(JOBPARTNERS_LABEL.HELLOWORK) + expect.soft(results.jobs[0].identifier.partner_label).not.toBe(JOBPARTNERS_LABEL.RH_ALTERNANCE) + expect.soft(results.jobs[1].identifier.partner_label).not.toBe(JOBPARTNERS_LABEL.RH_ALTERNANCE) + expect.soft(results.jobs[2].identifier.partner_label).not.toBe(JOBPARTNERS_LABEL.RH_ALTERNANCE) results = await findJobsOpportunities( { diff --git a/server/src/services/metiers.service.ts b/server/src/services/metiers.service.ts index f18695317..e5940130f 100644 --- a/server/src/services/metiers.service.ts +++ b/server/src/services/metiers.service.ts @@ -296,7 +296,7 @@ export const getCoupleAppellationRomeIntitule = async (searchTerm: string): Prom const metiers = await getCacheReferentielRome() const sorted = matchSorter(metiers, searchTerm, { keys: ["appellation"], - threshold: matchSorter.rankings.NO_MATCH, + threshold: matchSorter.rankings.ACRONYM, }) return { coupleAppellationRomeMetier: sorted.slice(0, 30) } diff --git a/server/static/templates/mail-candidat-entretien.mjml.ejs b/server/static/templates/mail-candidat-entretien.mjml.ejs index 38887382b..4496f697c 100644 --- a/server/static/templates/mail-candidat-entretien.mjml.ejs +++ b/server/static/templates/mail-candidat-entretien.mjml.ejs @@ -131,6 +131,7 @@ + <% if(data.phone) { %> Téléphone : @@ -141,6 +142,7 @@
+ <% } %> diff --git a/server/static/templates/mail-candidat-refus.mjml.ejs b/server/static/templates/mail-candidat-refus.mjml.ejs index 04ed2ba46..2d85cc200 100644 --- a/server/static/templates/mail-candidat-refus.mjml.ejs +++ b/server/static/templates/mail-candidat-refus.mjml.ejs @@ -95,7 +95,7 @@ - + <% } %> + diff --git a/shared/constants/application.ts b/shared/constants/application.ts index 526819e9c..0c5ce3b72 100644 --- a/shared/constants/application.ts +++ b/shared/constants/application.ts @@ -1,5 +1,20 @@ -export enum ApplicantIntention { +export enum ApplicationIntention { ENTRETIEN = "entretien", NESAISPAS = "ne_sais_pas", REFUS = "refus", } + +export enum ApplicationIntentionDefaultText { + ENTRETIEN = "Bonjour, \r\n\r\nMerci pour l'intérêt que vous portez à notre établissement. \r\n\r\nVotre candidature a retenu toute notre attention et nous souhaitons échanger avec vous. \r\n\r\nPouvez-vous me recontacter au numéro de téléphone ou via l'email ci-dessous afin que nous puissions convenir d'un rendez-vous ?", + REFUS = "Bonjour, \r\n\r\nMerci pour l'intérêt que vous portez à notre établissement. Nous avons étudié votre candidature avec attention. Nous ne sommes malheureusement pas en mesure de lui donner une suite favorable. \r\n\r\nNous vous souhaitons bonne chance dans vos recherches. \r\n\r\nBonne continuation", +} + +export enum RefusalReasons { + COMPETENCE = "Compétences insuffisantes ou non adaptées", + CANDIDATURE_NON_PERSONNALISEE = "Manque de personnalisation de la candidature", + ORTHOGRAPHE = "Fautes d'orthographe", + CV_PEU_CLAIR = "Manque de clarté du CV", + ECOLE = "Avis négatif sur l’école/la formation", + CONTRAT_INADAPTE = "Type de contrat inadapté", + METIER_RECHERCHE = "Notre entreprise ne recrute pas sur le métier recherché", +} diff --git a/shared/helpers/openapi/__snapshots__/generateOpenapi.test.ts.snap b/shared/helpers/openapi/__snapshots__/generateOpenapi.test.ts.snap index 68eb0f38e..b67e14e65 100644 --- a/shared/helpers/openapi/__snapshots__/generateOpenapi.test.ts.snap +++ b/shared/helpers/openapi/__snapshots__/generateOpenapi.test.ts.snap @@ -100,6 +100,24 @@ exports[`generateOpenApiSchema > should generate proper schema 1`] = ` "null", ], }, + "company_feedback_reasons": { + "items": { + "enum": [ + "Compétences insuffisantes ou non adaptées", + "Manque de personnalisation de la candidature", + "Fautes d'orthographe", + "Manque de clarté du CV", + "Avis négatif sur l’école/la formation", + "Type de contrat inadapté", + "Notre entreprise ne recrute pas sur le métier recherché", + ], + "type": "string", + }, + "type": [ + "array", + "null", + ], + }, "company_naf": { "description": "Code NAF de l'entreprise", "type": [ diff --git a/shared/models/applicantEmailLog.model.ts b/shared/models/applicantEmailLog.model.ts index 27b6dc385..c57aa3baf 100644 --- a/shared/models/applicantEmailLog.model.ts +++ b/shared/models/applicantEmailLog.model.ts @@ -1,15 +1,22 @@ +import { extensions } from "../helpers/zodHelpers/zodPrimitives" import { z } from "../helpers/zodWithOpenApi" import { IModelDescriptor, zObjectId } from "./common" const collectionName = "applicants_email_logs" as const +export enum EMAIL_LOG_TYPE { + RELANCE = "RELANCE", + NOTIFICATION = "NOTIFICATION", + INTENTION_ENTRETIEN = "INTENTION_ENTRETIEN", + INTENTION_REFUS = "INTENTION_REFUS", +} export const ZApplicantEmailLog = z .object({ _id: zObjectId, applicant_id: zObjectId, - type: z.enum(["RELANCE", "NOTIFICATION"]), - message_id: z.string(), + type: extensions.buildEnum(EMAIL_LOG_TYPE), + message_id: z.string().nullable(), createdAt: z.date(), }) .strict() diff --git a/shared/models/applications.model.ts b/shared/models/applications.model.ts index 3d117b85f..c391aad9d 100644 --- a/shared/models/applications.model.ts +++ b/shared/models/applications.model.ts @@ -1,6 +1,7 @@ import { ObjectId } from "bson" import { Jsonify } from "type-fest" +import { RefusalReasons } from "../constants/application" import { LBA_ITEM_TYPE, LBA_ITEM_TYPE_OLD, allLbaItemType, allLbaItemTypeOLD } from "../constants/lbaitem" import { removeUrlsFromText } from "../helpers/common" import { extensions } from "../helpers/zodHelpers/zodPrimitives" @@ -47,6 +48,7 @@ export const ZApplication = z job_searched_by_user: z.string().nullish().describe("Métier recherché par le candidat"), company_recruitment_intention: z.string().nullish().describe("L'intention de la société vis à vis du candidat"), company_feedback: z.string().nullish().describe("L'avis donné par la société"), + company_feedback_reasons: z.array(extensions.buildEnum(RefusalReasons)).nullish(), company_feedback_date: z.date().nullish().describe("Date d'intention/avis donnée"), company_siret: extensions.siret.describe("Siret de l'entreprise"), company_email: z.string().describe("Email de l'entreprise"), diff --git a/shared/models/models.ts b/shared/models/models.ts index 8ae636038..a0dffe51f 100644 --- a/shared/models/models.ts +++ b/shared/models/models.ts @@ -37,6 +37,7 @@ import rawHelloWorkModel from "./rawHelloWork.model" import rawKelioModel from "./rawKelio.model" import rawRHAlternanceModel from "./rawRHAlternance.model" import recruiterModel from "./recruiter.model" +import recruiterIntentionMailModel from "./recruiterIntentionMail.model" import lbaCompanyModel from "./recruteurLba.model" import lbaCompanyLegacyModel from "./recruteurLbaLegacy.model" import recruteurLbaUpdateEventModel from "./recruteurLbaUpdateEvent.model" @@ -91,6 +92,7 @@ const modelDescriptorMap = { [optoutModel.collectionName]: optoutModel, [rawHelloWorkModel.collectionName]: rawHelloWorkModel, [recruiterModel.collectionName]: recruiterModel, + [recruiterIntentionMailModel.collectionName]: recruiterIntentionMailModel, [recruteurLbaUpdateEventModel.collectionName]: recruteurLbaUpdateEventModel, [referentielOnisepModel.collectionName]: referentielOnisepModel, [referentielOpcoModel.collectionName]: referentielOpcoModel, diff --git a/shared/models/recruiterIntentionMail.model.ts b/shared/models/recruiterIntentionMail.model.ts new file mode 100644 index 000000000..5b2ec3343 --- /dev/null +++ b/shared/models/recruiterIntentionMail.model.ts @@ -0,0 +1,21 @@ +import { ApplicationIntention } from "../constants/application" +import { extensions } from "../helpers/zodHelpers/zodPrimitives" +import { z } from "../helpers/zodWithOpenApi" + +import { IModelDescriptor, zObjectId } from "./common" + +const collectionName = "recruiter_intention_mails" as const + +export const ZRecruiterIntentionMail = z.object({ + _id: zObjectId, + applicationId: zObjectId, + intention: extensions.buildEnum(ApplicationIntention), + createdAt: z.date(), +}) +export type IRecruiterIntentionMail = z.output + +export default { + zod: ZRecruiterIntentionMail, + indexes: [[{ applicationId: 1 }, {}]], + collectionName, +} as const satisfies IModelDescriptor diff --git a/shared/routes/application.routes.ts b/shared/routes/application.routes.ts index 9e1387cdc..66c3522fd 100644 --- a/shared/routes/application.routes.ts +++ b/shared/routes/application.routes.ts @@ -1,3 +1,4 @@ +import { ApplicationIntention, RefusalReasons } from "../constants/application" import { extensions } from "../helpers/zodHelpers/zodPrimitives" import { z } from "../helpers/zodWithOpenApi" import { ZLbacError } from "../models" @@ -38,13 +39,12 @@ export const zApplicationRoutes = { }, }, "/application/intention/:id": { - // TODO_SECURITY_FIX path: "/application/intention/:id", method: "post", params: z.object({ id: z.string() }).strict(), body: z .object({ - company_recruitment_intention: z.string(), + company_recruitment_intention: extensions.buildEnum(ApplicationIntention), }) .strict(), response: { @@ -67,9 +67,10 @@ export const zApplicationRoutes = { body: z .object({ company_feedback: z.string(), - company_recruitment_intention: z.string(), + company_recruitment_intention: extensions.buildEnum(ApplicationIntention), email: z.string().email().or(z.literal("")), phone: extensions.phone().or(z.literal("")), + refusal_reasons: z.array(extensions.buildEnum(RefusalReasons)), }) .strict(), response: { @@ -86,6 +87,24 @@ export const zApplicationRoutes = { resources: {}, }, }, + "/application/intention/cancel/:id": { + path: "/application/intention/cancel/:id", + method: "post", + params: z.object({ id: z.string() }).strict(), + response: { + "200": z + .object({ + result: z.literal("ok"), + message: z.literal("intention canceled"), + }) + .strict(), + }, + securityScheme: { + auth: "access-token", + access: null, + resources: {}, + }, + }, }, get: { "/application/company/email": { @@ -101,5 +120,26 @@ export const zApplicationRoutes = { }, securityScheme: null, }, + "/application/intention/schedule/:id": { + path: "/application/intention/schedule/:id", + method: "get", + params: z.object({ id: z.string() }).strict(), + querystring: z.object({ intention: extensions.buildEnum(ApplicationIntention) }).strict(), + response: { + "200": z + .object({ + recruiter_email: z.string(), + recruiter_phone: z.string(), + applicant_first_name: z.string(), + applicant_last_name: z.string(), + }) + .strict(), + }, + securityScheme: { + auth: "access-token", + access: null, + resources: {}, + }, + }, }, } as const satisfies IRoutesDef diff --git a/ui/components/IntentionForm.tsx/IntensionFormNavigation.tsx b/ui/components/IntentionForm.tsx/IntensionFormNavigation.tsx new file mode 100644 index 000000000..4038c1d5e --- /dev/null +++ b/ui/components/IntentionForm.tsx/IntensionFormNavigation.tsx @@ -0,0 +1,21 @@ +import { Flex, Image, Link, Spacer } from "@chakra-ui/react" +import NextLink from "next/link" +import React from "react" + +export const IntensionFormNavigation = () => { + return ( + + + + + + + + + + Page d'accueil La bonne alternance + + + + ) +} diff --git a/ui/components/IntentionForm.tsx/IntentionForm.tsx b/ui/components/IntentionForm.tsx/IntentionForm.tsx new file mode 100644 index 000000000..7a024f6e8 --- /dev/null +++ b/ui/components/IntentionForm.tsx/IntentionForm.tsx @@ -0,0 +1,256 @@ +import { Box, Button, Checkbox, CheckboxGroup, Flex, Stack, Text, Textarea } from "@chakra-ui/react" +import { Formik } from "formik" +import { useState } from "react" +import { useQuery } from "react-query" +import { ApplicationIntention, ApplicationIntentionDefaultText, RefusalReasons } from "shared/constants/application" +import * as Yup from "yup" + +import { SuccessCircle } from "@/theme/components/icons" +import { getApplicationDataForIntention } from "@/utils/api" + +import { apiPost } from "../../utils/api.utils" +import { CustomInput, LoadingEmptySpace } from "../espace_pro" +import { CustomFormControl } from "../espace_pro/CustomFormControl" + +import { IntensionFormNavigation } from "./IntensionFormNavigation" +import { IntensionFormResult } from "./IntentionFormResult" + +const textAreaProperties = { + border: "none", + background: "grey.200", + borderRadius: "4px 4px 0px 0px", + width: "100%", + height: "250px", + paddingLeft: 4, + borderBottom: "2px solid", +} + +const getText = ({ + applicant_first_name, + applicant_last_name, + company_recruitment_intention, +}: { + applicant_first_name: string + applicant_last_name: string + company_recruitment_intention: ApplicationIntention +}): { placeholderTextArea: string; header: React.ReactNode; confirmation: string } => { + switch (company_recruitment_intention) { + case ApplicationIntention.ENTRETIEN: + return { + header: ( + <> + + Personnalisez votre réponse pour apporter à {`${applicant_first_name}`} un message qui lui correspond vraiment. + + + ), + placeholderTextArea: ApplicationIntentionDefaultText.ENTRETIEN, + confirmation: `Votre réponse a été enregistrée et sera automatiquement envoyée à ${applicant_first_name} ${applicant_last_name}.`, + } + case ApplicationIntention.REFUS: + return { + header: ( + <> + + Personnalisez votre réponse afin d’aider {applicant_first_name} à s’améliorer pour ses prochaines candidatures. + + + ), + placeholderTextArea: ApplicationIntentionDefaultText.REFUS, + confirmation: `Votre refus a été enregistré et sera automatiquement envoyé à ${applicant_first_name} ${applicant_last_name}.`, + } + default: + return null + } +} + +export const IntentionForm = ({ company_recruitment_intention, id, token }: { company_recruitment_intention: ApplicationIntention; id: string; token: string | undefined }) => { + const { data, error } = useQuery("getApplicationDataForIntention", () => getApplicationDataForIntention(id, company_recruitment_intention, token), { + retry: false, + onError: (error: { message: string }) => console.log(`Something went wrong: ${error.message}`), + }) + + const [sendingState, setSendingState] = useState<"not_sent" | "ok_sent" | "not_sent_because_of_errors" | "canceled">("not_sent") + + const isRefusedState = company_recruitment_intention === ApplicationIntention.REFUS + + const submitForm = async ({ email, phone, company_feedback, refusal_reasons }: { email: string; phone: string; company_feedback: string; refusal_reasons: RefusalReasons[] }) => { + apiPost("/application/intentionComment/:id", { + params: { id }, + body: { + phone, + email, + company_feedback, + company_recruitment_intention, + refusal_reasons, + }, + headers: { + authorization: `Bearer ${token}`, + }, + }) + .then(() => setSendingState("ok_sent")) + .catch(() => setSendingState("not_sent_because_of_errors")) + } + + const cancelForm = async () => { + apiPost("/application/intention/cancel/:id", { + params: { id }, + headers: { + authorization: `Bearer ${token}`, + }, + }) + .then(() => setSendingState("canceled")) + .catch(() => setSendingState("not_sent_because_of_errors")) + } + + if (!data && !error) { + return ( + + + + + ) + } + + if (error) { + return ( + + + + + {error.message} + + + + ) + } + + const text = getText({ applicant_first_name: data.applicant_first_name, applicant_last_name: data.applicant_last_name, company_recruitment_intention }) + + return ( + + + {!["ok_sent", "canceled"].includes(sendingState) && ( + + + + {text.confirmation} + + + + {text.header} + + + submitForm(formikValues)} + > + {({ values, setFieldValue, handleChange, handleBlur, submitForm, isValid, isSubmitting }) => { + return ( + <> + + + + Le candidat recevra le message suivant {company_recruitment_intention === ApplicationIntention.ENTRETIEN && "ainsi que vos coordonnées "}par courriel. + +