From 3c898ccaecda0a4800575d3edbe4b09f1662e0bb Mon Sep 17 00:00:00 2001 From: Kevin Barnoin Date: Fri, 6 Dec 2024 16:54:05 +0100 Subject: [PATCH] feat(lbac-2216): update post application (#1662) * feat(backend): update post application * feat(front): update post application * fix: snapshot --- .talismanrc | 2 +- .../v2/applications.controller.v2.test.ts | 34 ++--- .../src/services/application.service.test.ts | 16 +-- server/src/services/application.service.ts | 126 ++++++++++-------- server/src/services/lbajob.service.ts | 11 +- server/src/services/recruteurLba.service.ts | 34 ++--- .../generateOpenapi.test.ts.snap | 10 ++ shared/models/applications.model.ts | 50 ++++--- shared/models/lbaItem.model.ts | 2 + shared/routes/application.routes.v2.ts | 6 +- .../CandidatureLba/CandidatureLba.tsx | 7 +- .../CandidatureLbaFileDropzone.tsx | 20 +-- .../CandidatureLba/CandidatureLbaMessage.tsx | 8 +- .../CandidatureLbaModalBody.tsx | 50 ++++--- .../CandidatureLba/WidgetCandidatureLba.tsx | 7 +- .../CandidatureLba/services/getSchema.ts | 41 +++--- .../services/submitCandidature.ts | 32 +++-- 17 files changed, 257 insertions(+), 199 deletions(-) diff --git a/.talismanrc b/.talismanrc index 8412c021e7..d25d4dad3b 100644 --- a/.talismanrc +++ b/.talismanrc @@ -74,7 +74,7 @@ fileignoreconfig: - filename: server/src/services/__snapshots__/formulaire.service.test.ts.snap checksum: 71d9956fd03fcec52d37b3799fc5bc806d42ec40200254b1740819b3d5a2b0d1 - filename: server/src/services/application.service.test.ts - checksum: 02c7f8874a880cc0f9913469d6ef097ad42bbea8df79d7f08451410f7b32e2c0 + checksum: d11d73a73ed489475d6a2f7dd693787fc71a7dfd9150bb88cec4f88c92c19d02 - filename: server/src/services/application.service.ts checksum: 38021ae663db3a146c848a8d691c0c1bc9bb262a80a6f2dedf9fcdb372eef3ac - filename: server/src/services/eligibleTrainingsForAppointment.service.ts 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 6f0708fd54..60e0927ada 100644 --- a/server/src/http/controllers/v2/applications.controller.v2.test.ts +++ b/server/src/http/controllers/v2/applications.controller.v2.test.ts @@ -1,7 +1,7 @@ import { useMongo } from "@tests/utils/mongo.test.utils" import { useServer } from "@tests/utils/server.test.utils" import { ObjectId } from "mongodb" -import { IApplicationApiJobId, IApplicationApiRecruteurId, JOB_STATUS } from "shared" +import { IApplicationApiPayload, JOB_STATUS } from "shared" import { NIVEAUX_POUR_LBA, RECRUITER_STATUS } from "shared/constants" import { LBA_ITEM_TYPE } from "shared/constants/lbaitem" import { applicationTestFile, wrongApplicationTestFile } from "shared/fixtures/application.fixture" @@ -118,14 +118,14 @@ describe("POST /v2/application", () => { }) it("Return 202 and create an application using a recruter lba", async () => { - const body: IApplicationApiRecruteurId = { - applicant_file_name: "cv.pdf", - applicant_file_content: applicationTestFile, + const body: IApplicationApiPayload = { + applicant_attachment_name: "cv.pdf", + applicant_attachment_content: applicationTestFile, applicant_email: "jeam.dupont@mail.com", applicant_first_name: "Jean", applicant_last_name: "Dupont", applicant_phone: "0101010101", - recruteur_id: recruteur._id.toString(), + recipient_id: `recruteurslba_${recruteur._id.toString()}`, } const response = await httpClient().inject({ @@ -142,7 +142,7 @@ describe("POST /v2/application", () => { expect(application).toEqual({ _id: expect.any(ObjectId), - applicant_attachment_name: body.applicant_file_name, + applicant_attachment_name: body.applicant_attachment_name, applicant_email: body.applicant_email, applicant_first_name: body.applicant_first_name, applicant_last_name: body.applicant_last_name, @@ -168,20 +168,20 @@ describe("POST /v2/application", () => { }) expect(s3Write).toHaveBeenCalledWith("applications", `cv-${application!._id}`, { - Body: body.applicant_file_content, + Body: body.applicant_attachment_content, }) }) it("Return 202 and create an application using a recruiter", async () => { const job = recruiter.jobs[0] - const body: IApplicationApiJobId = { - applicant_file_name: "cv.pdf", - applicant_file_content: applicationTestFile, + const body: IApplicationApiPayload = { + applicant_attachment_name: "cv.pdf", + applicant_attachment_content: applicationTestFile, applicant_email: "jeam.dupont@mail.com", applicant_first_name: "Jean", applicant_last_name: "Dupont", applicant_phone: "0101010101", - job_id: job._id.toString(), + recipient_id: `recruiters_${job._id.toString()}`, } const response = await httpClient().inject({ @@ -198,7 +198,7 @@ describe("POST /v2/application", () => { expect(application).toEqual({ _id: expect.any(ObjectId), - applicant_attachment_name: body.applicant_file_name, + applicant_attachment_name: body.applicant_attachment_name, applicant_email: body.applicant_email, applicant_first_name: body.applicant_first_name, applicant_last_name: body.applicant_last_name, @@ -225,19 +225,19 @@ describe("POST /v2/application", () => { }) expect(s3Write).toHaveBeenCalledWith("applications", `cv-${application!._id}`, { - Body: body.applicant_file_content, + Body: body.applicant_attachment_content, }) }) it("return 400 as file type is not supported", async () => { const job = recruiter.jobs[0] - const body: IApplicationApiJobId = { - applicant_file_name: "cv.pdf", - applicant_file_content: wrongApplicationTestFile, + const body: IApplicationApiPayload = { + applicant_attachment_name: "cv.pdf", + applicant_attachment_content: wrongApplicationTestFile, applicant_email: "jeam.dupont@mail.com", applicant_first_name: "Jean", applicant_last_name: "Dupont", applicant_phone: "0101010101", - job_id: job._id.toString(), + recipient_id: `recruiters_${job._id.toString()}`, } const response = await httpClient().inject({ diff --git a/server/src/services/application.service.test.ts b/server/src/services/application.service.test.ts index 1c4e16876f..4a1789ae03 100644 --- a/server/src/services/application.service.test.ts +++ b/server/src/services/application.service.test.ts @@ -22,9 +22,9 @@ const fakeApplication = { applicant_last_name: "a", applicant_email: "test@test.fr", applicant_phone: "0125252525", - message: "some blahblahblah", - applicant_file_name: "cv.pdf", - applicant_file_content: applicationTestFile, + applicant_message: "applicant message", + applicant_attachment_name: "cv.pdf", + applicant_attachment_content: applicationTestFile, } describe("Sending application", () => { beforeEach(async () => { @@ -93,7 +93,7 @@ describe("Sending application", () => { sendApplicationV2({ newApplication: { ...fakeApplication, - job_id: "6081289803569600282e0000", + recipient_id: { collectionName: "recruiters", jobId: "6081289803569600282e0000" }, }, }) ).rejects.toThrow(notFound(BusinessErrorCodes.NOTFOUND)) @@ -104,7 +104,7 @@ describe("Sending application", () => { sendApplicationV2({ newApplication: { ...fakeApplication, - job_id: "6081289803569600282e0001", + recipient_id: { collectionName: "recruiters", jobId: "6081289803569600282e0001" }, }, }) ).rejects.toThrow(badRequest(BusinessErrorCodes.EXPIRED)) @@ -115,7 +115,7 @@ describe("Sending application", () => { sendApplicationV2({ newApplication: { ...fakeApplication, - job_id: "6081289803569600282e0002", + recipient_id: { collectionName: "recruiters", jobId: "6081289803569600282e0002" }, }, }) ).rejects.toThrow(badRequest(BusinessErrorCodes.EXPIRED)) @@ -126,7 +126,7 @@ describe("Sending application", () => { sendApplicationV2({ newApplication: { ...fakeApplication, - job_id: "6081289803569600282e0003", + recipient_id: { collectionName: "recruiters", jobId: "6081289803569600282e0003" }, }, }) ).rejects.toThrow(badRequest(BusinessErrorCodes.EXPIRED)) @@ -137,7 +137,7 @@ describe("Sending application", () => { sendApplicationV2({ newApplication: { ...fakeApplication, - company_siret: "34843069553553", + recipient_id: { collectionName: "recruteurslba", jobId: "6081289803569600282e0003" }, }, }) ).rejects.toThrow(notFound(BusinessErrorCodes.NOTFOUND)) diff --git a/server/src/services/application.service.ts b/server/src/services/application.service.ts index 6ae05c1555..31494c7dab 100644 --- a/server/src/services/application.service.ts +++ b/server/src/services/application.service.ts @@ -3,25 +3,13 @@ import { isEmailBurner } from "burner-email-providers" import dayjs from "dayjs" import { fileTypeFromBuffer } from "file-type" import { ObjectId } from "mongodb" -import { - ApplicationScanStatus, - IApplication, - IApplicationApiJobId, - IApplicationApiRecruteurId, - IApplicationPrivateCompanySiret, - IApplicationPrivateJobId, - IJob, - ILbaCompany, - INewApplicationV1, - IRecruiter, - JOB_STATUS, - assertUnreachable, -} from "shared" +import { ApplicationScanStatus, IApplication, IApplicationApiPayloadOutput, IJob, ILbaCompany, INewApplicationV1, IRecruiter, JOB_STATUS, assertUnreachable } from "shared" import { ApplicantIntention } from "shared/constants/application" import { BusinessErrorCodes } from "shared/constants/errorCodes" import { LBA_ITEM_TYPE, LBA_ITEM_TYPE_OLD, 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 { ITrackingCookies } from "shared/models/trafficSources.model" import { IUserWithAccount } from "shared/models/userWithAccount.model" import { z } from "zod" @@ -80,7 +68,10 @@ const images: object = { }, } -type IJobOrCompany = { type: LBA_ITEM_TYPE.RECRUTEURS_LBA; job: ILbaCompany; recruiter: null } | { type: LBA_ITEM_TYPE.OFFRES_EMPLOI_LBA; job: IJob; recruiter: IRecruiter } +type IJobOrCompany = + | { type: LBA_ITEM_TYPE.RECRUTEURS_LBA; job: ILbaCompany; recruiter: null } + | { type: LBA_ITEM_TYPE.OFFRES_EMPLOI_LBA; job: IJob; recruiter: IRecruiter } + | { type: LBA_ITEM_TYPE.OFFRES_EMPLOI_PARTENAIRES; job: IJobsPartnersOfferPrivate; recruiter: null } export enum BlackListOrigins { CANDIDATURE_SPONTANEE_RECRUTEUR = "candidature_spontanee_recruteur", @@ -184,8 +175,8 @@ export const sendApplication = async ({ return { error: validationResult } } - const { type } = offreOrError - const recruteurEmail = (type === LBA_ITEM_TYPE.OFFRES_EMPLOI_LBA ? offreOrError.recruiter.email : offreOrError.job.email)?.toLowerCase() + const { type, job, recruiter } = offreOrError + const recruteurEmail = (type === LBA_ITEM_TYPE.OFFRES_EMPLOI_LBA ? recruiter.email : type === LBA_ITEM_TYPE.RECRUTEURS_LBA ? job.email : job.apply_email)?.toLowerCase() if (!recruteurEmail) { return { error: "email du recruteur manquant" } } @@ -238,38 +229,32 @@ export const sendApplicationV2 = async ({ caller, source, }: { - newApplication: IApplicationPrivateCompanySiret | IApplicationPrivateJobId | IApplicationApiRecruteurId | IApplicationApiJobId + newApplication: IApplicationApiPayloadOutput caller?: string source?: ITrackingCookies }): Promise<{ _id: ObjectId }> => { let lbaJob: IJobOrCompany = { type: null as any, job: null as any, recruiter: null } + const { + recipient_id: { collectionName, jobId }, + applicant_attachment_content, + applicant_email, + } = newApplication - await validateApplicationFileType(newApplication.applicant_file_content) + await validateApplicationFileType(applicant_attachment_content) - if (isEmailBurner(newApplication.applicant_email)) { + if (isEmailBurner(applicant_email)) { throw badRequest(BusinessErrorCodes.BURNER) } - if ("recruteur_id" in newApplication) { - // email can be null in collection - const LbaRecruteur = await getDbCollection("recruteurslba").findOne({ _id: new ObjectId(newApplication.recruteur_id), email: { $not: { $eq: null } } }) - if (!LbaRecruteur) { - throw badRequest(BusinessErrorCodes.NOTFOUND) - } - lbaJob = { type: LBA_ITEM_TYPE.RECRUTEURS_LBA, job: LbaRecruteur, recruiter: null } - } - - if ("company_siret" in newApplication) { - // email can be null in collection - const LbaRecruteur = await getDbCollection("recruteurslba").findOne({ siret: newApplication.company_siret, email: { $not: { $eq: null } } }) - if (!LbaRecruteur) { + if (collectionName === "recruteurslba") { + const job = await getDbCollection("recruteurslba").findOne({ _id: new ObjectId(jobId) }) + if (!job) { throw badRequest(BusinessErrorCodes.NOTFOUND) } - lbaJob = { type: LBA_ITEM_TYPE.RECRUTEURS_LBA, job: LbaRecruteur, recruiter: null } + lbaJob = { type: LBA_ITEM_TYPE.RECRUTEURS_LBA, job, recruiter: null } } - - if ("job_id" in newApplication) { - const recruiterResult = await getOffreAvecInfoMandataire(newApplication.job_id) + if (collectionName === "recruiters") { + const recruiterResult = await getOffreAvecInfoMandataire(jobId) if (!recruiterResult) { throw badRequest(BusinessErrorCodes.NOTFOUND) } @@ -281,11 +266,18 @@ export const sendApplicationV2 = async ({ lbaJob = { type: LBA_ITEM_TYPE.OFFRES_EMPLOI_LBA, job, recruiter } } + if (collectionName === "jobs_partners") { + const job = await getDbCollection("jobs_partners").findOne({ _id: new ObjectId(jobId) }) + if (!job) { + throw badRequest(BusinessErrorCodes.NOTFOUND) + } + lbaJob = { type: LBA_ITEM_TYPE.OFFRES_EMPLOI_PARTENAIRES, job, recruiter: null } + } await checkUserApplicationCountV2(newApplication.applicant_email.toLowerCase(), lbaJob, caller) const { type, job, recruiter } = lbaJob - const recruteurEmail = (type === LBA_ITEM_TYPE.OFFRES_EMPLOI_LBA ? recruiter.email : job.email)?.toLowerCase() + const recruteurEmail = (type === LBA_ITEM_TYPE.OFFRES_EMPLOI_LBA ? recruiter.email : type === LBA_ITEM_TYPE.RECRUTEURS_LBA ? job.email : job.apply_email)?.toLowerCase() if (!recruteurEmail) { sentryCaptureException(`${BusinessErrorCodes.INTERNAL_EMAIL} ${type === LBA_ITEM_TYPE.OFFRES_EMPLOI_LBA ? `recruiter: ${recruiter._id} ` : `LbaCompany: ${job._id}`}`) throw internal(BusinessErrorCodes.INTERNAL_EMAIL) @@ -294,7 +286,7 @@ export const sendApplicationV2 = async ({ try { const application = await newApplicationToApplicationDocumentV2(newApplication, lbaJob, caller) await s3Write("applications", getApplicationCvS3Filename(application), { - Body: newApplication.applicant_file_content, + Body: applicant_attachment_content, }) await getDbCollection("applications").insertOne(application) await saveApplicationTrafficSourceIfAny({ application_id: application._id, applicant_email: application.applicant_email, source }) @@ -434,7 +426,9 @@ const buildRecruiterEmailUrls = async (application: IApplication) => { return urls } -const offreOrCompanyToCompanyFields = (LbaJob: IJobOrCompany) => { +const offreOrCompanyToCompanyFields = ( + LbaJob: IJobOrCompany +): Pick => { const { type } = LbaJob if (type === LBA_ITEM_TYPE.RECRUTEURS_LBA) { const { job } = LbaJob @@ -464,6 +458,20 @@ const offreOrCompanyToCompanyFields = (LbaJob: IJobOrCompany) => { job_id: job._id.toString(), } return application + } else if (type === LBA_ITEM_TYPE.OFFRES_EMPLOI_PARTENAIRES) { + const { job } = LbaJob + const { workplace_siret, workplace_name, workplace_naf_label, apply_phone, apply_email, offer_title, workplace_address_label } = job + const application = { + company_siret: workplace_siret || "", + company_name: workplace_name || "Enseigne inconnue", + company_naf: workplace_naf_label || "", + company_phone: apply_phone, + company_email: apply_email || "", + job_title: offer_title ?? undefined, + company_address: workplace_address_label, + job_id: job._id.toString(), + } + return application } else { assertUnreachable(type) } @@ -506,17 +514,13 @@ const newApplicationToApplicationDocument = async (newApplication: INewApplicati /** * @description Initialize application object from query parameters */ -const newApplicationToApplicationDocumentV2 = async ( - newApplication: IApplicationApiRecruteurId | IApplicationApiJobId | IApplicationPrivateCompanySiret | IApplicationPrivateJobId, - LbaJob: IJobOrCompany, - caller?: string -) => { +const newApplicationToApplicationDocumentV2 = async (newApplication: IApplicationApiPayloadOutput, LbaJob: IJobOrCompany, caller?: string) => { const now = new Date() const application: IApplication = { ...offreOrCompanyToCompanyFields(LbaJob), applicant_first_name: newApplication.applicant_first_name, applicant_last_name: newApplication.applicant_last_name, - applicant_attachment_name: newApplication.applicant_file_name, + applicant_attachment_name: newApplication.applicant_attachment_name, applicant_email: newApplication.applicant_email.toLowerCase(), applicant_message_to_company: prepareMessageForMail(newApplication.applicant_message), applicant_phone: newApplication.applicant_phone, @@ -603,7 +607,7 @@ async function getApplicationCountForItem(applicantEmail: string, LbaJob: IJobOr applicant_email: applicantEmail.toLowerCase(), company_siret: job.siret, }) - } else if (type === LBA_ITEM_TYPE.OFFRES_EMPLOI_LBA) { + } else if (type === LBA_ITEM_TYPE.OFFRES_EMPLOI_LBA || type === LBA_ITEM_TYPE.OFFRES_EMPLOI_PARTENAIRES) { return getDbCollection("applications").countDocuments({ applicant_email: applicantEmail.toLowerCase(), job_id: job._id.toString(), @@ -624,7 +628,16 @@ const checkUserApplicationCount = async (applicantEmail: string, offreOrCompany: const end = new Date() end.setHours(23, 59, 59, 999) - const { type } = offreOrCompany + const { type, job, recruiter } = offreOrCompany + let siret: string | null = null + + if (type === LBA_ITEM_TYPE.OFFRES_EMPLOI_LBA) { + siret = recruiter.establishment_siret + } else if (type === LBA_ITEM_TYPE.RECRUTEURS_LBA) { + siret = job.siret + } else if (type === LBA_ITEM_TYPE.OFFRES_EMPLOI_PARTENAIRES) { + siret = job.workplace_siret + } const [todayApplicationsCount, itemApplicationCount, callerApplicationCount] = await Promise.all([ getDbCollection("applications").countDocuments({ @@ -632,10 +645,10 @@ const checkUserApplicationCount = async (applicantEmail: string, offreOrCompany: created_at: { $gte: start, $lt: end }, }), getApplicationCountForItem(applicantEmail, offreOrCompany), - caller + caller && siret ? getDbCollection("applications").countDocuments({ caller: caller.toLowerCase(), - company_siret: type === LBA_ITEM_TYPE.RECRUTEURS_LBA ? offreOrCompany.job.siret : offreOrCompany.recruiter.establishment_siret, + company_siret: siret, created_at: { $gte: start, $lt: end }, }) : 0, @@ -667,6 +680,15 @@ const checkUserApplicationCountV2 = async (applicantEmail: string, LbaJob: IJobO end.setHours(23, 59, 59, 999) const { type, job, recruiter } = LbaJob + let siret: string | null = null + + if (type === LBA_ITEM_TYPE.OFFRES_EMPLOI_LBA) { + siret = recruiter.establishment_siret + } else if (type === LBA_ITEM_TYPE.RECRUTEURS_LBA) { + siret = job.siret + } else if (type === LBA_ITEM_TYPE.OFFRES_EMPLOI_PARTENAIRES) { + siret = job.workplace_siret + } const [todayApplicationsCount, itemApplicationCount, callerApplicationCount] = await Promise.all([ getDbCollection("applications").countDocuments({ @@ -674,10 +696,10 @@ const checkUserApplicationCountV2 = async (applicantEmail: string, LbaJob: IJobO created_at: { $gte: start, $lt: end }, }), getApplicationCountForItem(applicantEmail, LbaJob), - caller + caller && siret ? getDbCollection("applications").countDocuments({ caller, - company_siret: type === LBA_ITEM_TYPE.RECRUTEURS_LBA ? job.siret : recruiter.establishment_siret, + company_siret: siret, created_at: { $gte: start, $lt: end }, }) : 0, diff --git a/server/src/services/lbajob.service.ts b/server/src/services/lbajob.service.ts index 8abff99cec..b723359556 100644 --- a/server/src/services/lbajob.service.ts +++ b/server/src/services/lbajob.service.ts @@ -313,17 +313,16 @@ export const getLbaJobById = async ({ id, caller }: { id: ObjectId; caller?: str return { error: "not_found" } } - const applicationCountByJob = await getApplicationByJobCount([id.toString()]) + if (caller) { + trackApiCall({ caller: caller, job_count: 1, result_count: 1, api_path: "jobV1/matcha", response: "OK" }) + } + const applicationCountByJob = await getApplicationByJobCount([id.toString()]) const job = transformLbaJob({ recruiter: rawJob.recruiter, applicationCountByJob, }) - if (caller) { - trackApiCall({ caller: caller, job_count: 1, result_count: 1, api_path: "jobV1/matcha", response: "OK" }) - } - return { matchas: job } } catch (error) { sentryCaptureException(error) @@ -455,6 +454,7 @@ function transformLbaJob({ recruiter, applicationCountByJob }: { recruiter: Part romes, applicationCount: applicationCountForCurrentJob?.count || 0, token: generateApplicationToken({ jobId: offre._id.toString() }), + recipient_id: `recruiters_${offre._id.toString()}`, } //TODO: remove when 1j1s switch to api V2 @@ -505,6 +505,7 @@ function transformLbaJobWithMinimalData({ recruiter, applicationCountByJob }: { }, applicationCount: applicationCountForCurrentJob?.count || 0, token: generateApplicationToken({ jobId: offre._id.toString() }), + recipient_id: `recruiters_${offre._id.toString()}`, } return resultJob diff --git a/server/src/services/recruteurLba.service.ts b/server/src/services/recruteurLba.service.ts index e752e9a3bd..cf65ad3a5c 100644 --- a/server/src/services/recruteurLba.service.ts +++ b/server/src/services/recruteurLba.service.ts @@ -79,6 +79,7 @@ const transformCompany = ({ applicationCount: applicationCount?.count || 0, url: null, token: generateApplicationToken({ company_siret: company.siret }), + recipient_id: `recruteurslba_${company._id.toString()}`, } return resultCompany @@ -116,6 +117,7 @@ const transformCompanyWithMinimalData = ({ company, applicationCountByCompany }: ], applicationCount: applicationCount?.count || 0, token: generateApplicationToken({ company_siret: company.siret }), + recipient_id: `recruteurslba_${company._id.toString()}`, } return resultCompany @@ -361,28 +363,26 @@ export const getCompanyFromSiret = async ({ try { const lbaCompany = await getDbCollection("recruteurslba").findOne({ siret }) - if (lbaCompany) { - const applicationCountByCompany = await getApplicationByCompanyCount([lbaCompany.siret]) - - const company = transformCompany({ - company: lbaCompany, - contactAllowedOrigin: isAllowedSource({ referer, caller }), - caller, - applicationCountByCompany, - }) - - if (caller) { - trackApiCall({ caller, api_path: "jobV1/company", job_count: 1, result_count: 1, response: "OK" }) - } - - return { lbaCompanies: [company] } - } else { + if (!lbaCompany) { if (caller) { trackApiCall({ caller, api_path: "jobV1/company", job_count: 0, result_count: 0, response: "OK" }) } - return { error: "not_found", status: 404, result: "not_found", message: "Société non trouvée" } } + + if (caller) { + trackApiCall({ caller, api_path: "jobV1/company", job_count: 1, result_count: 1, response: "OK" }) + } + + const applicationCountByCompany = await getApplicationByCompanyCount([lbaCompany.siret]) + const company = transformCompany({ + company: lbaCompany, + contactAllowedOrigin: isAllowedSource({ referer, caller }), + caller, + applicationCountByCompany, + }) + + return { lbaCompanies: [company] } } catch (error) { sentryCaptureException(error) return manageApiError({ diff --git a/shared/helpers/openapi/__snapshots__/generateOpenapi.test.ts.snap b/shared/helpers/openapi/__snapshots__/generateOpenapi.test.ts.snap index 3db10ccd6e..9e9ade9880 100644 --- a/shared/helpers/openapi/__snapshots__/generateOpenapi.test.ts.snap +++ b/shared/helpers/openapi/__snapshots__/generateOpenapi.test.ts.snap @@ -1577,6 +1577,10 @@ exports[`generateOpenApiSchema > should generate proper schema 1`] = ` "place": { "$ref": "#/components/schemas/Place", }, + "recipient_id": { + "description": "Identifiant personnalisé (ID mongoDB préfixé du nom de la collection) envoyé au server pour la candidature", + "type": "string", + }, "title": { "type": [ "string", @@ -1602,6 +1606,7 @@ exports[`generateOpenApiSchema > should generate proper schema 1`] = ` "place", "company", "applicationCount", + "recipient_id", ], "type": "object", }, @@ -1839,6 +1844,10 @@ exports[`generateOpenApiSchema > should generate proper schema 1`] = ` "place": { "$ref": "#/components/schemas/Place", }, + "recipient_id": { + "description": "Identifiant personnalisé (ID mongoDB préfixé du nom de la collection) envoyé au server pour la candidature", + "type": "string", + }, "romes": { "items": { "$ref": "#/components/schemas/Rome", @@ -1875,6 +1884,7 @@ exports[`generateOpenApiSchema > should generate proper schema 1`] = ` "company", "id", "applicationCount", + "recipient_id", ], "type": "object", }, diff --git a/shared/models/applications.model.ts b/shared/models/applications.model.ts index 31729d46df..20721f4ce4 100644 --- a/shared/models/applications.model.ts +++ b/shared/models/applications.model.ts @@ -1,3 +1,5 @@ +import { Jsonify } from "type-fest" + import { LBA_ITEM_TYPE, LBA_ITEM_TYPE_OLD, allLbaItemType, allLbaItemTypeOLD } from "../constants/lbaitem" import { removeUrlsFromText } from "../helpers/common" import { extensions } from "../helpers/zodHelpers/zodPrimitives" @@ -19,20 +21,20 @@ export enum ApplicationScanStatus { export const ZApplication = z .object({ _id: zObjectId, - applicant_email: z.string().email().describe("Email du candidat"), + applicant_email: z.string({ required_error: "⚠ L'adresse e-mail est obligatoire" }).email("⚠ Adresse e-mail invalide").describe("Email du candidat"), applicant_first_name: z - .string() + .string({ required_error: "⚠ Le prénom est obligatoire" }) .max(50) .transform((value) => removeUrlsFromText(value)) .describe("Prenom du candidat"), applicant_last_name: z - .string() + .string({ required_error: "⚠ Le nom est obligatoire" }) .max(50) .transform((value) => removeUrlsFromText(value)) .describe("Nom du candidat"), applicant_phone: extensions.phone().describe("Téléphone du candidat"), applicant_attachment_name: z - .string() + .string({ required_error: "⚠ La pièce jointe est obligatoire" }) .regex(/((.*?))(\.)+(docx|pdf)$/i) .describe("Nom du fichier du CV du candidat. Seuls les .docx et .pdf sont autorisés."), applicant_message_to_company: z.string().nullable().describe("Un message du candidat vers le recruteur. Ce champ peut contenir la lettre de motivation du candidat."), @@ -171,34 +173,30 @@ const ZApplicationV2Base = ZApplication.pick({ applicant_last_name: true, applicant_email: true, applicant_phone: true, + applicant_attachment_name: true, + job_searched_by_user: true, caller: true, }).extend({ applicant_message: ZApplication.shape.applicant_message_to_company.optional(), - applicant_file_name: ZApplication.shape.applicant_attachment_name, - applicant_file_content: z.string().max(4_215_276).describe("Le contenu du fichier du CV du candidat. La taille maximale autorisée est de 3 Mo."), -}) - -export const ZApplicationApiRecruteurId = ZApplicationV2Base.extend({ - recruteur_id: z.string().describe("Identifiant unique du recruteur issue de La bonne alternance"), -}) -export type IApplicationApiRecruteurId = z.output - -export const ZApplicationApiJobId = ZApplicationV2Base.extend({ - job_id: z.string().describe("Identifiant unique de l'offre d'emploi issue de La bonne alternance"), -}) -export type IApplicationApiJobId = z.output - -export const ZApplicationPrivateCompanySiret = ZApplicationV2Base.extend({ - company_siret: ZApplication.shape.company_siret, - job_searched_by_user: ZApplication.shape.job_searched_by_user, + applicant_attachment_content: z.string().max(4_215_276).describe("Le contenu du fichier du CV du candidat. La taille maximale autorisée est de 3 Mo."), }) -export type IApplicationPrivateCompanySiret = z.output -export const ZApplicationPrivateJobId = ZApplicationV2Base.extend({ - job_id: z.string().describe("Identifiant unique de l'offre LBA"), - job_searched_by_user: ZApplication.shape.job_searched_by_user, +type JobCollectionName = "recruteurslba" | "jobs_partners" | "recruiters" +export const ZApplicationApiPayload = ZApplicationV2Base.extend({ + recipient_id: z + .string() + .transform((recipientId) => { + const [collectionName, jobId] = recipientId.split("_") + if (!["recruteurslba", "jobs_parnters", "recruiters"].includes(collectionName)) { + throw new Error(`Invalid collection name: ${collectionName}`) + } + return { collectionName: collectionName as JobCollectionName, jobId } + }) + .describe("Identifiant unique de la ressource vers laquelle la candidature est faite, préfixé par le nom de la collection"), }) -export type IApplicationPrivateJobId = z.output +export type IApplicationApiPayloadOutput = z.output +export type IApplicationApiPayload = z.input +export type IApplicationApiPayloadJSON = Jsonify> export default { zod: ZApplication, diff --git a/shared/models/lbaItem.model.ts b/shared/models/lbaItem.model.ts index d14daa7fb3..22d8e8e6c7 100644 --- a/shared/models/lbaItem.model.ts +++ b/shared/models/lbaItem.model.ts @@ -480,6 +480,7 @@ export const ZLbaItemLbaJob = z applicationCount: z.number(), // calcul en fonction du nombre de candidatures enregistrées detailsLoaded: z.boolean().nullish(), token: z.string().nullish(), // KBA 2024_05_20 : for API V2 only, remove nullish when fully migrated + recipient_id: z.string().describe("Identifiant personnalisé (ID mongoDB préfixé du nom de la collection) envoyé au server pour la candidature"), }) .strict() .openapi("LbaJob") @@ -502,6 +503,7 @@ export const ZLbaItemLbaCompany = z applicationCount: z.number(), // calcul en fonction du nombre de candidatures enregistrées detailsLoaded: z.boolean().nullish(), token: z.string().nullish(), // KBA 2024_05_20 : for API V2 only, remove nullish when fully migrated + recipient_id: z.string().describe("Identifiant personnalisé (ID mongoDB préfixé du nom de la collection) envoyé au server pour la candidature"), }) .strict() .openapi("LbaCompany") diff --git a/shared/routes/application.routes.v2.ts b/shared/routes/application.routes.v2.ts index 594b02acbb..8cdd3c9a7a 100644 --- a/shared/routes/application.routes.v2.ts +++ b/shared/routes/application.routes.v2.ts @@ -1,5 +1,5 @@ import { z } from "../helpers/zodWithOpenApi" -import { ZApplicationApiJobId, ZApplicationApiRecruteurId, ZApplicationPrivateCompanySiret, ZApplicationPrivateJobId } from "../models" +import { ZApplicationApiPayload } from "../models" import { IRoutesDef } from "./common.routes" @@ -8,7 +8,7 @@ export const zApplicationRoutesV2 = { "/application": { path: "/application", method: "post", - body: z.union([ZApplicationApiRecruteurId, ZApplicationApiJobId]), + body: ZApplicationApiPayload, response: { "202": z.object({ id: z.string(), @@ -19,7 +19,7 @@ export const zApplicationRoutesV2 = { "/_private/application": { path: "/_private/application", method: "post", - body: z.union([ZApplicationPrivateCompanySiret, ZApplicationPrivateJobId]), + body: ZApplicationApiPayload, response: { "200": z.object({}), }, diff --git a/ui/components/ItemDetail/CandidatureLba/CandidatureLba.tsx b/ui/components/ItemDetail/CandidatureLba/CandidatureLba.tsx index 8c01f677dc..f6c34de47a 100644 --- a/ui/components/ItemDetail/CandidatureLba/CandidatureLba.tsx +++ b/ui/components/ItemDetail/CandidatureLba/CandidatureLba.tsx @@ -3,6 +3,7 @@ import { useFormik } from "formik" import { useEffect, useState } from "react" import { LBA_ITEM_TYPE_OLD } from "shared/constants/lbaitem" import { JOB_STATUS } from "shared/models/job.model" +import { toFormikValidationSchema } from "zod-formik-adapter" import LBAModalCloseButton from "@/components/lbaModalCloseButton" @@ -13,7 +14,7 @@ import ItemDetailApplicationsStatus, { hasApplied } from "../ItemDetailServices/ import CandidatureLbaFailed from "./CandidatureLbaFailed" import CandidatureLbaModalBody from "./CandidatureLbaModalBody" import CandidatureLbaWorked from "./CandidatureLbaWorked" -import { getInitialSchemaValues, getValidationSchema } from "./services/getSchema" +import { ApplicationFormikSchema, getInitialSchemaValues } from "./services/getSchema" import { useSubmitCandidature } from "./services/submitCandidature" export const NoCandidatureLba = () => { @@ -53,7 +54,7 @@ const CandidatureLba = ({ item }) => { const formik = useFormik({ initialValues: getInitialSchemaValues(), - validationSchema: getValidationSchema(), + validationSchema: toFormikValidationSchema(ApplicationFormikSchema), onSubmit: async (formValues) => { await submitCandidature({ formValues, setSendingState, LbaJob: item }) }, @@ -95,7 +96,7 @@ const CandidatureLba = ({ item }) => { {["not_sent", "currently_sending"].includes(sendingState) && ( )} - {sendingState === "ok_sent" && } + {sendingState === "ok_sent" && } {!["not_sent", "ok_sent", "currently_sending"].includes(sendingState) && } diff --git a/ui/components/ItemDetail/CandidatureLba/CandidatureLbaFileDropzone.tsx b/ui/components/ItemDetail/CandidatureLba/CandidatureLbaFileDropzone.tsx index 83484aef7f..eaeda9531b 100644 --- a/ui/components/ItemDetail/CandidatureLba/CandidatureLbaFileDropzone.tsx +++ b/ui/components/ItemDetail/CandidatureLba/CandidatureLbaFileDropzone.tsx @@ -1,10 +1,14 @@ import { Box, Button, Flex, FormControl, FormErrorMessage, Image, Input, Spinner, Text } from "@chakra-ui/react" import * as Sentry from "@sentry/nextjs" -import React, { useState } from "react" +import { useState } from "react" import { useDropzone } from "react-dropzone" const CandidatureLbaFileDropzone = ({ setFileValue, formik }) => { - const [fileData, setFileData] = useState(formik.values.fileName ? { fileName: formik.values.fileName, fileContent: formik.values.fileContent } : null) + const [fileData, setFileData] = useState<{ applicant_attachment_name: string; applicant_attachment_content: string | ArrayBuffer } | null>( + formik.values.applicant_attachment_name + ? { applicant_attachment_name: formik.values.applicant_attachment_name, applicant_attachment_content: formik.values.applicant_attachment_content } + : null + ) const [fileLoading, setFileLoading] = useState(false) const [showUnacceptedFileMessages, setShowUnacceptedFileMessages] = useState(false) @@ -14,15 +18,15 @@ const CandidatureLbaFileDropzone = ({ setFileValue, formik }) => { } const hasSelectedFile = () => { - return fileData?.fileName + return fileData?.applicant_attachment_name } const onDrop = (files) => { const reader = new FileReader() - let fileName = null + let applicant_attachment_name = null reader.onload = (e) => { - const readFileData = { fileName, fileContent: e.target.result } + const readFileData = { applicant_attachment_name, applicant_attachment_content: e.target.result } setFileData(readFileData) setFileValue(readFileData) } @@ -39,7 +43,7 @@ const CandidatureLbaFileDropzone = ({ setFileValue, formik }) => { } if (files.length) { - fileName = files[0].name + applicant_attachment_name = files[0].name reader.readAsDataURL(files[0]) } else { setShowUnacceptedFileMessages(true) @@ -77,7 +81,7 @@ const CandidatureLbaFileDropzone = ({ setFileValue, formik }) => { )} {showUnacceptedFileMessages && ⚠ Le fichier n'est pas au bon format (autorisé : .docx ou .pdf, <3mo, max 1 fichier)} - {formik.errors.fileName} + {formik.errors.applicant_attachment_name} ) } @@ -85,7 +89,7 @@ const CandidatureLbaFileDropzone = ({ setFileValue, formik }) => { const getSelectedFile = () => { return ( - Pièce jointe : {fileData.fileName} + Pièce jointe : {fileData.applicant_attachment_name} {