Skip to content

Commit

Permalink
feat(lba-2262): évolution réponse à candidat (#1725)
Browse files Browse the repository at this point in the history
* feat: déport logique router dans composant

* fix: gestion du cas

* feat: partie back wip

* feat: partie front wip

* feat: version cancel visuel seulement

* feat: style bouton cancel

* feat: aération texte

* feat: téléphone optionnel

* feat: modèle email

* feat: cancel intention

* feat: canceling ok

* feat: save et cancel ok

* feat: suppression immédiate intention lors de l'envoi immédiat

* feat: avanti

* fix: application du texte correct

* feat: looks good

* feat: enregistrement refusal_reasons

* fix: tests

* feat: job ok

* feat: snap updated

* fix: route auth schema

* feat: rename route 1

* feat: rename route 2

* feat: jobOrCompany

* feat: passage en mode pipeline

* feat: stream ok

* feat: factorisation et email_logs

* feat: premier test ok

* feat: tests controllers ok

* feat: mock envoi d'email

* feat: skip until mail mock is cracked

* feat: tests

* feat: skipping

* feat: diminution de la permissivité

* fix: style CTA

* feat: textarea plus haute

* feat: affichage conditionnel du phone

* fix: détection correcte type de message

* correction typo réponse négative recruteur

* maj motifs refus candidature

* fix: snapshot

* fix: test

---------

Co-authored-by: guilletmarion <[email protected]>
Co-authored-by: Kevin Barnoin <[email protected]>
  • Loading branch information
3 people authored Jan 15, 2025
1 parent 3842ddb commit 73f3296
Show file tree
Hide file tree
Showing 28 changed files with 873 additions and 86 deletions.
2 changes: 1 addition & 1 deletion .talismanrc
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions server/src/http/controllers/application.controller.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
61 changes: 37 additions & 24 deletions server/src/http/controllers/application.controller.ts
Original file line number Diff line number Diff line change
@@ -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 = {
Expand Down Expand Up @@ -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",
{
Expand Down Expand Up @@ -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 })
}
)
}
125 changes: 123 additions & 2 deletions server/src/http/controllers/v2/applications.controller.v2.test.ts
Original file line number Diff line number Diff line change
@@ -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"

Expand Down Expand Up @@ -63,12 +67,19 @@ const recruteur = generateLbaCompanyFixture({
last_update_at: new Date("2024-07-04T23:24:58.995Z"),
})

const recruiterEmailFixture = "[email protected]"

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: "[email protected]",
email: recruiterEmailFixture,
jobs: [
{
rome_code: ["M1602"],
Expand All @@ -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,
Expand All @@ -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)
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -212,6 +247,7 @@ describe("POST /v2/application", () => {
company_address: "Paris",
company_email: "[email protected]",
company_feedback: null,
company_feedback_reasons: null,
company_naf: "",
company_name: "ASSEMBLEE NATIONALE",
company_phone: "0300000000",
Expand Down Expand Up @@ -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: "[email protected]", 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: "[email protected]",
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,
})
})
})
5 changes: 5 additions & 0 deletions server/src/jobs/applications/processRecruiterIntentions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { processScheduledRecruiterIntentions } from "@/services/application.service"

export const processRecruiterIntentions = async () => {
await processScheduledRecruiterIntentions()
}
5 changes: 5 additions & 0 deletions server/src/jobs/jobs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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": {
Expand Down
5 changes: 5 additions & 0 deletions server/src/jobs/simpleJobDefinitions.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { processScheduledRecruiterIntentions } from "@/services/application.service"
import { generateSitemap } from "@/services/sitemap.service"

import { anonymizeApplicantsAndApplications } from "./anonymization/anonymizeApplicantAndApplications"
Expand Down Expand Up @@ -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",
Expand Down
17 changes: 16 additions & 1 deletion server/src/services/appLinks.service.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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,
[
Expand All @@ -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",
Expand Down
Loading

0 comments on commit 73f3296

Please sign in to comment.