diff --git a/server/src/http/controllers/jobs.controller.v2.ts b/server/src/http/controllers/jobs.controller.v2.ts index ccee8ad5f9..deca122622 100644 --- a/server/src/http/controllers/jobs.controller.v2.ts +++ b/server/src/http/controllers/jobs.controller.v2.ts @@ -1,6 +1,7 @@ import Boom from "boom" import { IJob, ILbaItemFtJob, ILbaItemLbaJob, JOB_STATUS, assertUnreachable, zRoutes } from "shared" import { LBA_ITEM_TYPE } from "shared/constants/lbaitem" +import { IJobOpportunityFranceTravailRncp, IJobOpportunityFranceTravailRome, IJobOpportunityRncp, IJobOpportunityRome } from "shared/routes/jobOpportunity.routes" import { getDbCollection } from "@/common/utils/mongodbUtils" import { getUserFromRequest } from "@/security/authenticationService" @@ -10,6 +11,7 @@ import { getFileSignedURL } from "../../common/utils/awsUtils" import { trackApiCall } from "../../common/utils/sendTrackingEvent" import { sentryCaptureException } from "../../common/utils/sentryUtils" import { getNearEtablissementsFromRomes } from "../../services/catalogue.service" +import { getRomesFromRncp } from "../../services/certification.service" import { ACTIVE, ANNULEE, POURVUE } from "../../services/constant.service" import dayjs from "../../services/dayjs.service" import { entrepriseOnboardingWorkflow } from "../../services/etablissement.service" @@ -26,10 +28,10 @@ import { patchOffre, provideOffre, } from "../../services/formulaire.service" -import { getFtJobFromIdV2 } from "../../services/ftjob.service" -import { getJobsQuery } from "../../services/jobOpportunity.service" -import { getCompanyFromSiret } from "../../services/lbacompany.service" -import { addOffreDetailView, getLbaJobByIdV2 } from "../../services/lbajob.service" +import { getFtJobFromIdV2, getFtJobs } from "../../services/ftjob.service" +import { formatFranceTravailToJobOpportunity, formatOffreEmploiLbaToJobOpportunity, formatRecruteurLbaToJobOpportunity, getJobsQuery } from "../../services/jobOpportunity.service" +import { getCompanyFromSiret, getRecruteursLbaFromDB } from "../../services/lbacompany.service" +import { addOffreDetailView, getJobs, getLbaJobByIdV2 } from "../../services/lbajob.service" import { getFicheMetierFromDB } from "../../services/rome.service" import { Server } from "../server" @@ -486,4 +488,108 @@ export default (server: Server) => { } } ) + + server.get( + "/jobs/rome/recruteurs_lba", + { schema: zRoutes.get["/jobs/rome/recruteurs_lba"], onRequest: server.auth(zRoutes.get["/jobs/rome/recruteurs_lba"]) }, + async (req, res) => { + const payload: IJobOpportunityRome = req.query + const result = await getRecruteursLbaFromDB(payload) + return res.send(formatRecruteurLbaToJobOpportunity(result)) + } + ) + + server.get( + "/jobs/rncp/recruteurs_lba", + { schema: zRoutes.get["/jobs/rncp/recruteurs_lba"], onRequest: server.auth(zRoutes.get["/jobs/rncp/recruteurs_lba"]) }, + async (req, res) => { + const payload: IJobOpportunityRncp = req.query + const romes = await getRomesFromRncp(payload.rncp) + if (!romes) { + throw Boom.internal(`Aucun code ROME n'a été trouvé à partir du code RNCP ${payload.rncp}`) + } + const result = await getRecruteursLbaFromDB({ ...payload, romes }) + return res.send(formatRecruteurLbaToJobOpportunity(result)) + } + ) + + server.get( + "/jobs/rome/offres_emploi_lba", + { schema: zRoutes.get["/jobs/rome/offres_emploi_lba"], onRequest: server.auth(zRoutes.get["/jobs/rome/offres_emploi_lba"]) }, + async (req, res) => { + const payload: IJobOpportunityRome = req.query + const result = await getJobs({ + romes: payload.romes, + distance: payload.radius, + niveau: payload.diploma, + lat: payload.latitude, + lon: payload.longitude, + isMinimalData: false, + }) + return res.send(formatOffreEmploiLbaToJobOpportunity(result)) + } + ) + + server.get( + "/jobs/rncp/offres_emploi_lba", + { schema: zRoutes.get["/jobs/rncp/offres_emploi_lba"], onRequest: server.auth(zRoutes.get["/jobs/rncp/offres_emploi_lba"]) }, + async (req, res) => { + const payload: IJobOpportunityRncp = req.query + const romes = await getRomesFromRncp(payload.rncp) + if (!romes) { + throw Boom.internal(`Aucun code ROME n'a été trouvé à partir du code RNCP ${payload.rncp}`) + } + const result = await getJobs({ + romes, + distance: payload.radius, + niveau: payload.diploma, + lat: payload.latitude, + lon: payload.longitude, + isMinimalData: false, + }) + return res.send(formatOffreEmploiLbaToJobOpportunity(result)) + } + ) + + server.get( + "/jobs/rome/offres_emploi_partenaires", + { schema: zRoutes.get["/jobs/rome/offres_emploi_partenaires"], onRequest: server.auth(zRoutes.get["/jobs/rome/offres_emploi_partenaires"]) }, + async () => {} + ) + + server.get( + "/jobs/rncp/offres_emploi_partenaires", + { schema: zRoutes.get["/jobs/rncp/offres_emploi_partenaires"], onRequest: server.auth(zRoutes.get["/jobs/rncp/offres_emploi_partenaires"]) }, + async () => {} + ) + + server.get( + "/jobs/rome/offres_emploi_france_travail", + { schema: zRoutes.get["/jobs/rome/offres_emploi_france_travail"], onRequest: server.auth(zRoutes.get["/jobs/rome/offres_emploi_france_travail"]) }, + async (req, res) => { + const payload: IJobOpportunityFranceTravailRome = req.query + const result = await getFtJobs({ jobLimit: 150, caller: "api-apprentissage", api: zRoutes.get["/jobs/offres_emploi_france_travail"].path, ...payload }) + if ("error" in result) { + throw Boom.internal(result.message) + } + return res.send(formatFranceTravailToJobOpportunity(result.resultats)) + } + ) + + server.get( + "/jobs/rncp/offres_emploi_france_travail", + { schema: zRoutes.get["/jobs/rncp/offres_emploi_france_travail"], onRequest: server.auth(zRoutes.get["/jobs/rncp/offres_emploi_france_travail"]) }, + async (req, res) => { + const payload: IJobOpportunityFranceTravailRncp = req.query + const romes = await getRomesFromRncp(payload.rncp) + if (!romes) { + throw Boom.internal(`Aucun code ROME n'a été trouvé à partir du code RNCP ${payload.rncp}`) + } + const result = await getFtJobs({ romes, jobLimit: 150, caller: "api-apprentissage", api: zRoutes.get["/jobs/offres_emploi_france_travail"].path, ...payload }) + if ("error" in result) { + throw Boom.internal(result.message) + } + return res.send(formatFranceTravailToJobOpportunity(result.resultats)) + } + ) } diff --git a/server/src/jobs/lba_recruteur/formulaire/fixRecruiterDataValidation.ts b/server/src/jobs/lba_recruteur/formulaire/fixRecruiterDataValidation.ts index 5b7cfc3ae3..4084a89abc 100644 --- a/server/src/jobs/lba_recruteur/formulaire/fixRecruiterDataValidation.ts +++ b/server/src/jobs/lba_recruteur/formulaire/fixRecruiterDataValidation.ts @@ -112,7 +112,7 @@ const fixJobRythm = async () => { if (job.job_rythm === "1 jours / 4 jours") { job.job_rythm = TRAINING_RYTHM["1J4J"] } else if (job.job_rythm === "" || job.job_rythm === "Non renseigné") { - job.job_rythm = null + job.job_rythm = TRAINING_RYTHM.INDIFFERENT } await updateOffre(job._id, { ...job }) }) diff --git a/server/src/services/certification.service.ts b/server/src/services/certification.service.ts new file mode 100644 index 0000000000..b2c2b2525e --- /dev/null +++ b/server/src/services/certification.service.ts @@ -0,0 +1,44 @@ +import axios from "axios" + +import { sentryCaptureException } from "../common/utils/sentryUtils" +import config from "../config" + +import { CertificationAPIApprentissage } from "./queryValidator.service.types" + +const getFirstCertificationFromAPIApprentissage = async (rncp: string): Promise => { + try { + const { data } = await axios.get(`${config.apiApprentissage.baseUrl}/certification/v1?identifiant.rncp=${rncp}`, { + headers: { Authorization: `Bearer ${config.apiApprentissage.apiKey}` }, + }) + + if (!data.length) return null + + return data[0] + } catch (error: any) { + sentryCaptureException(error, { responseData: error.response?.data }) + return null + } +} + +const getRomesFromCertification = (certification: CertificationAPIApprentissage) => { + return certification.domaines.rome.rncp.map((x) => x.code) +} +export const getRomesFromRncp = async (rncp: string): Promise => { + let certification = await getFirstCertificationFromAPIApprentissage(rncp) + if (!certification) { + return null + } + if (certification.periode_validite.rncp.actif) { + return getRomesFromCertification(certification) + } else { + const latestRNCP = certification.continuite.rncp.findLast((rncp) => rncp.actif === true) + if (!latestRNCP) { + return getRomesFromCertification(certification) + } + certification = await getFirstCertificationFromAPIApprentissage(latestRNCP.code) + if (!certification) { + return null + } + return getRomesFromCertification(certification) + } +} diff --git a/server/src/services/ftjob.service.ts b/server/src/services/ftjob.service.ts index 86c6923362..74a8c33243 100644 --- a/server/src/services/ftjob.service.ts +++ b/server/src/services/ftjob.service.ts @@ -206,7 +206,7 @@ const transformFtJobs = ({ jobs, radius, latitude, longitude, isMinimalData }: { /** * Récupère une liste d'offres depuis l'API France Travail */ -const getFtJobs = async ({ +export const getFtJobs = async ({ romes, insee, radius, @@ -222,7 +222,7 @@ const getFtJobs = async ({ caller: string diploma: string api: string -}) => { +}): Promise => { try { const peContratsAlternances = "E2,FS" //E2 -> Contrat d'Apprentissage, FS -> contrat de professionalisation diff --git a/server/src/services/ftjob.service.types.ts b/server/src/services/ftjob.service.types.ts index 130500545c..19fe92fbd2 100644 --- a/server/src/services/ftjob.service.types.ts +++ b/server/src/services/ftjob.service.types.ts @@ -15,6 +15,25 @@ type FTEntreprise = { siret: string } +type FTFormation = { + codeFormatio: string + domaineLibelle: string + niveauLibelle: string + exigence: string +} + +type FTOrigineOffre = { + origine: string + urlOrigine: string + partenaires: FTPartenaire[] +} + +type FTPartenaire = { + nom: string + url: string + logo: string +} + export type FTJob = { id: string intitule: string @@ -54,9 +73,9 @@ export type FTJob = { secteurActivite?: string secteurActiviteLibelle?: string qualitesProfessionnelles?: [][] - origineOffre: object[] + origineOffre: FTOrigineOffre offresManqueCandidats?: boolean - formations?: [][] + formations?: FTFormation[] langues?: [][] complementExercice?: string appellationLibelle: string diff --git a/server/src/services/jobOpportunity.service.ts b/server/src/services/jobOpportunity.service.ts index 8c0bb3f2e5..d784e0c020 100644 --- a/server/src/services/jobOpportunity.service.ts +++ b/server/src/services/jobOpportunity.service.ts @@ -1,9 +1,13 @@ -import { LBA_ITEM_TYPE, allLbaItemType } from "shared/constants/lbaitem" +import { ILbaCompany, IRecruiter } from "shared" +import { JOB_OPPORTUNITY_TYPE, LBA_ITEM_TYPE, allLbaItemType } from "shared/constants/lbaitem" +import { IJobOpportunity } from "shared/interface/jobOpportunity.types" import { IApiError } from "../common/utils/errorManager" import { trackApiCall } from "../common/utils/sendTrackingEvent" +import config from "../config" import { getSomeFtJobs } from "./ftjob.service" +import { FTJob } from "./ftjob.service.types" import { TJobSearchQuery, TLbaItemResult } from "./jobOpportunity.service.types" import { getSomeCompanies } from "./lbacompany.service" import { ILbaItemFtJob, ILbaItemLbaCompany, ILbaItemLbaJob } from "./lbaitem.shared.service.types" @@ -168,3 +172,151 @@ export const getJobsQuery = async ( return result } + +export const formatRecruteurLbaToJobOpportunity = (recruteursLba: ILbaCompany[]): IJobOpportunity[] => { + return recruteursLba.map((recruteurLba) => ({ + identifiant: { + id: recruteurLba.siret, + type: JOB_OPPORTUNITY_TYPE.RECRUTEURS_LBA, + }, + contract: null, + jobOffre: null, + workplace: { + description: null, + name: recruteurLba.enseigne ?? recruteurLba.raison_sociale, + siret: recruteurLba.siret, + size: recruteurLba.company_size, + website: recruteurLba.website, + location: { + address: `${recruteurLba.street_number} ${recruteurLba.street_name} ${recruteurLba.zip_code} ${recruteurLba.city}`, + latitude: parseFloat(recruteurLba.geo_coordinates.split(",")[0]), + longitude: parseFloat(recruteurLba.geo_coordinates.split(",")[1]), + }, + domaine: { + opco: recruteurLba.opco, + idcc: null, + naf: { + code: recruteurLba.naf_code, + label: recruteurLba.naf_label, + }, + }, + }, + apply: { + url: `${config.publicUrl}/recherche-apprentissage?type=lba&itemId=${recruteurLba.siret}`, + phone: recruteurLba.phone, + email: recruteurLba.email, + }, + })) +} + +export const formatOffreEmploiLbaToJobOpportunity = (offresEmploiLba: IRecruiter[]): IJobOpportunity[] => { + return offresEmploiLba.flatMap((offreEmploiLba) => + offreEmploiLba.jobs.map((job) => ({ + identifiant: { + id: job._id, + type: JOB_OPPORTUNITY_TYPE.OFFRES_EMPLOI_LBA, + }, + contract: job.job_type, + jobOffre: { + title: job.rome_appellation_label!, + start: job.job_start_date, + duration: job.job_duration!, + immediateStart: null, + description: job.rome_detail!.definition!, + diplomaLevelLabel: job.job_level_label!, + desiredSkills: job.rome_detail!.competences.savoir_etre_professionnel!.map((x) => x.libelle), + toBeAcquiredSkills: job.rome_detail!.competences.savoir_faire!.map((x) => ({ libelle: x.libelle, items: x.items.map((y) => y.libelle) })), + accessCondition: job.rome_detail!.acces_metier, + remote: null, + publication: { + creation: job.job_creation_date!, + expiration: job.job_expiration_date!, + }, + meta: { + origin: offreEmploiLba.origin!, + count: job.job_count!, + }, + }, + workplace: { + siret: offreEmploiLba.establishment_siret, + name: offreEmploiLba.establishment_enseigne! ?? offreEmploiLba.establishment_raison_sociale!, + description: null, + size: offreEmploiLba.establishment_size!, + website: null, + location: { + address: offreEmploiLba.address!, + latitude: offreEmploiLba.geopoint!.coordinates[1], + longitude: offreEmploiLba.geopoint!.coordinates[0], + }, + domaine: { + idcc: Number(offreEmploiLba.idcc) ?? null, + opco: offreEmploiLba.opco!, + naf: { + code: offreEmploiLba.naf_code!, + label: offreEmploiLba.naf_label!, + }, + }, + }, + apply: { + url: `${config.publicUrl}/recherche-apprentissage?type=matcha&itemId=${job._id}`, + phone: offreEmploiLba.phone!, + email: offreEmploiLba.email, + }, + })) + ) +} + +export const formatFranceTravailToJobOpportunity = (offresEmploiFranceTravail: FTJob[]): IJobOpportunity[] => { + return offresEmploiFranceTravail.map((offreFT) => ({ + identifiant: { + id: offreFT.id, + type: JOB_OPPORTUNITY_TYPE.OFFRES_EMPLOI_FRANCE_TRAVAIL, + }, + contract: [offreFT.natureContrat], + jobOffre: { + title: offreFT.intitule, + start: null, + duration: offreFT.typeContratLibelle, + immediateStart: null, + description: offreFT.description, + diplomaLevelLabel: null, + desiredSkills: null, + toBeAcquiredSkills: null, + accessCondition: offreFT.formations ? offreFT.formations?.map((formation) => `${formation.domaineLibelle} - ${formation.niveauLibelle}`) : null, + remote: null, + publication: { + creation: new Date(offreFT.dateCreation), + expiration: null, + }, + meta: { + origin: null, + count: offreFT.nombrePostes, + }, + }, + workplace: { + siret: null, + name: offreFT.entreprise.nom, + description: offreFT.entreprise.description, + size: null, + website: null, + location: { + address: offreFT.lieuTravail.libelle, + longitude: parseFloat(offreFT.lieuTravail.longitude), + latitude: parseFloat(offreFT.lieuTravail.latitude), + }, + domaine: { + idcc: null, + opco: null, + naf: { + code: offreFT.codeNAF ? offreFT.codeNAF : null, + label: offreFT.secteurActiviteLibelle ? offreFT.secteurActiviteLibelle : null, + }, + }, + }, + apply: { + url: offreFT.origineOffre.partenaires[0].url ?? offreFT.origineOffre.urlOrigine, + phone: null, + email: null, + }, + })) +} diff --git a/server/src/services/lbacompany.service.ts b/server/src/services/lbacompany.service.ts index 0b6e21fc0b..dc5d8a93cf 100644 --- a/server/src/services/lbacompany.service.ts +++ b/server/src/services/lbacompany.service.ts @@ -159,10 +159,46 @@ const transformCompanies = ({ return transformedCompanies } +type IRecruteursLbaSearchParams = { + romes: string[] + latitude: number + longitude: number + radius: number + opco?: string + opcoUrl?: string +} + +export const getRecruteursLbaFromDB = async ({ radius = 10, romes, opco, opcoUrl, latitude, longitude }: IRecruteursLbaSearchParams): Promise => { + const query: { rome_codes: object; opco_short_name?: string; opco_url?: string } = { + rome_codes: { $in: romes }, + } + if (opco) { + query.opco_short_name = opco.toUpperCase() + } + if (opcoUrl) { + query.opco_url = opcoUrl.toLowerCase() + } + return (await getDbCollection("recruteurslba") + .aggregate([ + { + $geoNear: { + near: { type: "Point", coordinates: [longitude, latitude] }, + distanceField: "distance", + maxDistance: radius * 1000, + query, + }, + }, + { + $limit: 150, + }, + ]) + .toArray()) as ILbaCompany[] +} + /** * Retourne des sociétés issues de l'algo matchant les critères en paramètres */ -const getCompanies = async ({ +export const getCompanies = async ({ romes, latitude, longitude, diff --git a/server/src/services/queryValidator.service.ts b/server/src/services/queryValidator.service.ts index c25b52dc23..ad5975ae33 100644 --- a/server/src/services/queryValidator.service.ts +++ b/server/src/services/queryValidator.service.ts @@ -1,51 +1,10 @@ -import axios from "axios" import { allLbaItemTypeOLD } from "shared/constants/lbaitem" import { isOriginLocal } from "../common/utils/isOriginLocal" import { regionCodeToDepartmentList } from "../common/utils/regionInseeCodes" -import { sentryCaptureException } from "../common/utils/sentryUtils" -import config from "../config" +import { getRomesFromRncp } from "./certification.service" import { TFormationSearchQuery, TJobSearchQuery } from "./jobOpportunity.service.types" -import { CertificationAPIApprentissage } from "./queryValidator.service.types" - -const getFirstCertificationFromAPIApprentissage = async (rncp: string): Promise => { - try { - const { data } = await axios.get(`${config.apiApprentissage.baseUrl}/certification/v1?identifiant.rncp=${rncp}`, { - headers: { Authorization: `Bearer ${config.apiApprentissage.apiKey}` }, - }) - - if (!data.length) return null - - return data[0] - } catch (error: any) { - sentryCaptureException(error, { responseData: error.response?.data }) - return null - } -} - -const getRomesFromCertification = (certification: CertificationAPIApprentissage) => { - return certification.domaines.rome.rncp.map((x) => x.code).join(",") -} -const getRomesFromRncp = async (rncp: string): Promise => { - let certification = await getFirstCertificationFromAPIApprentissage(rncp) - if (!certification) { - return null - } - if (certification.periode_validite.rncp.actif) { - return getRomesFromCertification(certification) - } else { - const latestRNCP = certification.continuite.rncp.findLast((rncp) => rncp.actif === true) - if (!latestRNCP) { - return getRomesFromCertification(certification) - } - certification = await getFirstCertificationFromAPIApprentissage(latestRNCP.code) - if (!certification) { - return null - } - return getRomesFromCertification(certification) - } -} /** * Contrôle le format d'un code RNCP @@ -86,7 +45,7 @@ const validateRomesOrRncp = async (query: Omit if (!romesFromRncp) { error_messages.push(`rncp : Rncp code not recognized. Please check that it exists. (${rncp})`) } else { - query.romes = romesFromRncp + query.romes = romesFromRncp.join(",") } } } else { diff --git a/shared/constants/lbaitem.ts b/shared/constants/lbaitem.ts index 71af2e067b..3d8bf5e2bd 100644 --- a/shared/constants/lbaitem.ts +++ b/shared/constants/lbaitem.ts @@ -7,6 +7,13 @@ export enum LBA_ITEM_TYPE { OFFRES_EMPLOI_PARTENAIRES = "offres_emploi_partenaires", } +export enum JOB_OPPORTUNITY_TYPE { + RECRUTEURS_LBA = "recruteurs_lba", + OFFRES_EMPLOI_LBA = "offres_emploi_lba", + OFFRES_EMPLOI_PARTENAIRES = "offres_emploi_partenaires", + OFFRES_EMPLOI_FRANCE_TRAVAIL = "offres_emploi_france_travail", +} + export enum LBA_ITEM_TYPE_OLD { FORMATION = "formation", MATCHA = "matcha", diff --git a/shared/constants/recruteur.ts b/shared/constants/recruteur.ts index fd61b0f2d9..9a0952bf43 100644 --- a/shared/constants/recruteur.ts +++ b/shared/constants/recruteur.ts @@ -60,6 +60,7 @@ export enum OPCOS { } export const NIVEAUX_POUR_LBA = { + INDIFFERENT: "Indifférent", "3 (CAP...)": "Cap, autres formations niveau (Infrabac)", "4 (BAC...)": "BP, Bac, autres formations niveau (Bac)", "5 (BTS, DEUST...)": "BTS, DEUST, autres formations niveau (Bac+2)", @@ -92,3 +93,9 @@ export const TRAINING_RYTHM = { "2S3S": "2 semaines / 3 semaines", "6S6S": "6 semaines / 6 semaines", } + +export const TRAINING_REMOTE_TYPE = { + PRESENTIEL: "Présentiel", + TELETRAVAIL: "Télétravail", + HYBRID: "Hybride", +} diff --git a/shared/constants/regex.ts b/shared/constants/regex.ts index 588fa589b6..52d814f089 100644 --- a/shared/constants/regex.ts +++ b/shared/constants/regex.ts @@ -5,14 +5,6 @@ export const REPETITION_VOIE_MAPPING = { ["Quinquiès"]: "C", } -export const CFD_REGEX_PATTERN = "^[A-Z0-9]{8}$" -export const CODE_INSEE_PATTERN = "^[0-9]{1}[0-9A-Z]{1}[0-9]{3}$" -export const CODE_POSTAL_PATTERN = "^[0-9]{5}$" -export const RNCP_REGEX_PATTERN = "^(RNCP)?[0-9]{2,5}$" -export const SIRET_REGEX_PATTERN = "^[0-9]{14}$" -export const CODE_NAF_REGEX_PATTERN = "^[0-9]{4}[A-Z]$" -export const UAI_REGEX_PATTERN = "^[0-9]{7}[a-zA-Z]$" - // Numero INE (Identifiant National Elève) // Le numero INE composé de 11 caractères, // soit 10 chiffres et 1 lettre soit 9 chiffres et 2 lettres (depuis la rentrée 2018). @@ -24,13 +16,17 @@ const INE_BEA_REGEX_PATTERN = "^[0-9_]{10}[a-zA-Z]{1}$" const INE_APPRENTISSAGE_REGEX_PATTERN = "^[0-9]{4}A[0-9]{5}[a-zA-Z]{1}$" export const INE_REGEX_PATTERN = `^(${INE_RNIE_REGEX_PATTERN}|${INE_BEA_REGEX_PATTERN}|${INE_APPRENTISSAGE_REGEX_PATTERN})$` -export const CFD_REGEX = new RegExp(CFD_REGEX_PATTERN) -export const CODE_POSTAL_REGEX = new RegExp(CODE_POSTAL_PATTERN) +export const CFD_REGEX = new RegExp("^[A-Z0-9]{8}$") +export const CODE_POSTAL_REGEX = new RegExp("^[0-9]{5}$") export const INE_REGEX = new RegExp(INE_REGEX_PATTERN) -export const RNCP_REGEX = new RegExp(RNCP_REGEX_PATTERN) -export const SIRET_REGEX = new RegExp(SIRET_REGEX_PATTERN) -export const CODE_NAF_REGEX = new RegExp(CODE_NAF_REGEX_PATTERN) -export const UAI_REGEX = new RegExp(UAI_REGEX_PATTERN) +export const RNCP_REGEX = new RegExp("^(RNCP)?[0-9]{2,5}$") +export const SIRET_REGEX = new RegExp("^[0-9]{14}$") +export const CODE_NAF_REGEX = new RegExp("^[0-9]{4}[A-Z]$") +export const UAI_REGEX = new RegExp("^[0-9]{7}[a-zA-Z]$") +export const CODE_ROME_REGEX = new RegExp("^[A-Z]d{4}$") +export const LATITUDE_REGEX = new RegExp("^[-+]?([1-8]?d(.d+)?|90(.0+)?)$") +export const LONGITUDE_REGEX = new RegExp("^[-+]?((1[0-7]d(.d+)?|180(.0+)?|d{1,2}(.d+)?))$") +export const CODE_INSEE_REGEX = new RegExp("^[0-9]{1}[0-9A-Z]{1}[0-9]{3}$") export const isValidCFD = (cfd: string) => typeof cfd === "string" && CFD_REGEX.test(cfd) export const isValidINE = (ine: string) => typeof ine === "string" && INE_REGEX.test(ine) diff --git a/shared/helpers/openapi/__snapshots__/generateOpenapi.test.ts.snap b/shared/helpers/openapi/__snapshots__/generateOpenapi.test.ts.snap index 8e06a5cbb0..62b741acb2 100644 --- a/shared/helpers/openapi/__snapshots__/generateOpenapi.test.ts.snap +++ b/shared/helpers/openapi/__snapshots__/generateOpenapi.test.ts.snap @@ -788,6 +788,7 @@ exports[`generateOpenApiSchema > should generate proper schema 1`] = ` "job_level_label": { "description": "Niveau de formation visé en fin de stage", "enum": [ + "Indifférent", "Cap, autres formations niveau (Infrabac)", "BP, Bac, autres formations niveau (Bac)", "BTS, DEUST, autres formations niveau (Bac+2)", @@ -817,10 +818,7 @@ exports[`generateOpenApiSchema > should generate proper schema 1`] = ` "2 semaines / 3 semaines", "6 semaines / 6 semaines", ], - "type": [ - "string", - "null", - ], + "type": "string", }, "job_start_date": { "description": "Date de début de l'alternance", @@ -1222,6 +1220,7 @@ exports[`generateOpenApiSchema > should generate proper schema 1`] = ` "job_status", "job_type", "is_multi_published", + "job_rythm", ], "type": "object", }, @@ -3621,6 +3620,7 @@ Limite : 7 appel(s) / 1 seconde(s) "required": false, "schema": { "enum": [ + "INDIFFERENT", "3 (CAP...)", "4 (BAC...)", "5 (BTS, DEUST...)", @@ -4413,6 +4413,7 @@ Limite : 7 appel(s) / 1 seconde(s) "required": false, "schema": { "enum": [ + "INDIFFERENT", "3 (CAP...)", "4 (BAC...)", "5 (BTS, DEUST...)", @@ -4846,6 +4847,7 @@ Limite : 7 appel(s) / 1 seconde(s) "required": false, "schema": { "enum": [ + "INDIFFERENT", "3 (CAP...)", "4 (BAC...)", "5 (BTS, DEUST...)", @@ -5307,6 +5309,7 @@ Limite : 5 appel(s) / 1 seconde(s) "required": false, "schema": { "enum": [ + "INDIFFERENT", "3 (CAP...)", "4 (BAC...)", "5 (BTS, DEUST...)", @@ -6327,6 +6330,7 @@ Limite : 5 appel(s) / 1 seconde(s) "required": false, "schema": { "enum": [ + "INDIFFERENT", "3 (CAP...)", "4 (BAC...)", "5 (BTS, DEUST...)", @@ -6642,6 +6646,7 @@ Limite : 5 appel(s) / 1 seconde(s) "job_level_label": { "description": "Niveau de formation visé en fin de stage", "enum": [ + "Indifférent", "Cap, autres formations niveau (Infrabac)", "BP, Bac, autres formations niveau (Bac)", "BTS, DEUST, autres formations niveau (Bac+2)", @@ -6664,10 +6669,7 @@ Limite : 5 appel(s) / 1 seconde(s) "2 semaines / 3 semaines", "6 semaines / 6 semaines", ], - "type": [ - "string", - "null", - ], + "type": "string", }, "job_start_date": { "description": "Date de début de l'alternance", @@ -6687,6 +6689,7 @@ Limite : 5 appel(s) / 1 seconde(s) }, "required": [ "job_type", + "job_rythm", "is_multi_published", "job_start_date", "appellation_code", @@ -6814,6 +6817,7 @@ Limite : 5 appel(s) / 1 seconde(s) "job_level_label": { "description": "Niveau de formation visé en fin de stage", "enum": [ + "Indifférent", "Cap, autres formations niveau (Infrabac)", "BP, Bac, autres formations niveau (Bac)", "BTS, DEUST, autres formations niveau (Bac+2)", @@ -6836,10 +6840,7 @@ Limite : 5 appel(s) / 1 seconde(s) "2 semaines / 3 semaines", "6 semaines / 6 semaines", ], - "type": [ - "string", - "null", - ], + "type": "string", }, "job_start_date": { "description": "Date de début de l'alternance", @@ -7097,6 +7098,7 @@ Limite : 5 appel(s) / 1 seconde(s) "required": false, "schema": { "enum": [ + "INDIFFERENT", "3 (CAP...)", "4 (BAC...)", "5 (BTS, DEUST...)", @@ -7752,6 +7754,7 @@ Limite : 7 appel(s) / 1 seconde(s) "required": false, "schema": { "enum": [ + "INDIFFERENT", "3 (CAP...)", "4 (BAC...)", "5 (BTS, DEUST...)", @@ -8044,6 +8047,7 @@ Limite : 7 appel(s) / 1 seconde(s) "required": false, "schema": { "enum": [ + "INDIFFERENT", "3 (CAP...)", "4 (BAC...)", "5 (BTS, DEUST...)", @@ -8218,6 +8222,7 @@ Limite : 5 appel(s) / 1 seconde(s) "required": false, "schema": { "enum": [ + "INDIFFERENT", "3 (CAP...)", "4 (BAC...)", "5 (BTS, DEUST...)", @@ -8420,6 +8425,7 @@ Limite : 5 appel(s) / 1 seconde(s) "required": false, "schema": { "enum": [ + "INDIFFERENT", "3 (CAP...)", "4 (BAC...)", "5 (BTS, DEUST...)", @@ -9607,6 +9613,7 @@ Limite : 5 appel(s) / 1 seconde(s) "required": false, "schema": { "enum": [ + "INDIFFERENT", "3 (CAP...)", "4 (BAC...)", "5 (BTS, DEUST...)", @@ -9920,6 +9927,7 @@ Limite : 5 appel(s) / 1 seconde(s) "job_level_label": { "description": "Niveau de formation visé en fin de stage", "enum": [ + "Indifférent", "Cap, autres formations niveau (Infrabac)", "BP, Bac, autres formations niveau (Bac)", "BTS, DEUST, autres formations niveau (Bac+2)", @@ -9942,10 +9950,7 @@ Limite : 5 appel(s) / 1 seconde(s) "2 semaines / 3 semaines", "6 semaines / 6 semaines", ], - "type": [ - "string", - "null", - ], + "type": "string", }, "job_start_date": { "description": "Date de début de l'alternance", @@ -9965,6 +9970,7 @@ Limite : 5 appel(s) / 1 seconde(s) }, "required": [ "job_type", + "job_rythm", "is_multi_published", "job_start_date", "appellation_code", @@ -10091,6 +10097,7 @@ Limite : 5 appel(s) / 1 seconde(s) "job_level_label": { "description": "Niveau de formation visé en fin de stage", "enum": [ + "Indifférent", "Cap, autres formations niveau (Infrabac)", "BP, Bac, autres formations niveau (Bac)", "BTS, DEUST, autres formations niveau (Bac+2)", @@ -10113,10 +10120,7 @@ Limite : 5 appel(s) / 1 seconde(s) "2 semaines / 3 semaines", "6 semaines / 6 semaines", ], - "type": [ - "string", - "null", - ], + "type": "string", }, "job_start_date": { "description": "Date de début de l'alternance", @@ -10278,6 +10282,7 @@ Limite : 5 appel(s) / 1 seconde(s) "required": false, "schema": { "enum": [ + "INDIFFERENT", "3 (CAP...)", "4 (BAC...)", "5 (BTS, DEUST...)", diff --git a/shared/helpers/zodHelpers/zodPrimitives.ts b/shared/helpers/zodHelpers/zodPrimitives.ts index 7868974554..210bda81e1 100644 --- a/shared/helpers/zodHelpers/zodPrimitives.ts +++ b/shared/helpers/zodHelpers/zodPrimitives.ts @@ -1,6 +1,6 @@ import { capitalize } from "lodash-es" -import { CODE_NAF_REGEX, SIRET_REGEX, UAI_REGEX } from "../../constants/regex" +import { CODE_INSEE_REGEX, CODE_NAF_REGEX, CODE_ROME_REGEX, LATITUDE_REGEX, LONGITUDE_REGEX, RNCP_REGEX, SIRET_REGEX, UAI_REGEX } from "../../constants/regex" import { validateSIRET } from "../../validators/siretValidator" import { removeUrlsFromText } from "../common" import { z } from "../zodWithOpenApi" @@ -101,4 +101,19 @@ export const extensions = { } return z.enum([values[0], ...values.slice(1)]) }, + romeCode: () => z.string().trim().regex(CODE_ROME_REGEX, "Code ROME invalide"), + rncpCode: () => z.string().trim().regex(RNCP_REGEX, "Code RNCP invalide"), + latitude: () => + z + .string() + .trim() + .regex(LATITUDE_REGEX, "Latitude invalide") + .transform((val) => parseFloat(val)), + longitude: () => + z + .string() + .trim() + .regex(LONGITUDE_REGEX, "Longitude invalide") + .transform((val) => parseFloat(val)), + inseeCode: () => z.string().trim().regex(CODE_INSEE_REGEX, "Code INSEE invalide"), } diff --git a/shared/interface/jobOpportunity.types.ts b/shared/interface/jobOpportunity.types.ts new file mode 100644 index 0000000000..c892e51df4 --- /dev/null +++ b/shared/interface/jobOpportunity.types.ts @@ -0,0 +1,71 @@ +import { z } from "zod" +import { zObjectId } from "zod-mongodb-schema" + +import { NIVEAUX_POUR_LBA, OPCOS, TRAINING_CONTRACT_TYPE, TRAINING_REMOTE_TYPE } from "../constants" +import { JOB_OPPORTUNITY_TYPE } from "../constants/lbaitem" +import { extensions } from "../helpers/zodHelpers/zodPrimitives" + +const zJobOpportunityIdentifiant = z.object({ + id: z.union([zObjectId, extensions.siret]), + type: extensions.buildEnum(JOB_OPPORTUNITY_TYPE), +}) + +const zJobOpportunityContract = z.array(extensions.buildEnum(TRAINING_CONTRACT_TYPE)) + +const zJobOpportunityApply = z.object({ + url: z.string().nullable(), + phone: z.string().nullable(), + email: z.string().nullable(), +}) + +const zJobOpportunityWorkplace = z.object({ + siret: extensions.siret.nullable(), + name: z.string(), + description: z.string().nullable(), + size: z.string().nullable(), + website: z.string().nullable(), + location: z.object({ + address: z.string(), + latitude: z.number(), + longitude: z.number(), + }), + domaine: z.object({ + idcc: z.number().nullable(), + opco: extensions.buildEnum(OPCOS).nullable(), + naf: z.object({ + code: z.string().nullable(), + label: z.string().nullable(), + }), + }), +}) + +const zJobOpportunityOffer = z.object({ + title: z.string(), + start: z.date().nullable(), + duration: z.union([z.number(), z.string()]).nullable(), + immediateStart: z.boolean().nullable(), + description: z.string(), + diplomaLevelLabel: extensions.buildEnum(NIVEAUX_POUR_LBA).nullable(), + desiredSkills: z.union([z.array(z.any()), z.string()]).nullable(), + toBeAcquiredSkills: z.union([z.array(z.any()), z.string()]).nullable(), + accessCondition: z.union([z.array(z.any()), z.string()]).nullable(), + remote: extensions.buildEnum(TRAINING_REMOTE_TYPE).nullable(), + publication: z.object({ + creation: z.date(), + expiration: z.date().nullable(), + }), + meta: z.object({ + origin: z.string().nullable(), + count: z.number(), + }), +}) + +export const zJobOpportunity = z.object({ + identifiant: zJobOpportunityIdentifiant, + contract: zJobOpportunityContract.nullable(), + jobOffre: zJobOpportunityOffer.nullable(), + workplace: zJobOpportunityWorkplace, + apply: zJobOpportunityApply, +}) + +export type IJobOpportunity = z.output diff --git a/shared/models/job.model.ts b/shared/models/job.model.ts index 690b82ac9b..fb758cc543 100644 --- a/shared/models/job.model.ts +++ b/shared/models/job.model.ts @@ -57,10 +57,7 @@ export const ZJobFields = z is_disabled_elligible: z.boolean().nullish().default(false).describe("Poste ouvert aux personnes en situation de handicap"), job_count: z.number().nullish().default(1).describe("Nombre de poste(s) ouvert(s) pour cette offre"), job_duration: z.number().min(6).max(36).nullish().describe("Durée du contrat en mois"), - job_rythm: z - .enum([allJobRythm[0], ...allJobRythm.slice(1)]) - .nullish() - .describe("Répartition de la présence de l'alternant en formation/entreprise"), + job_rythm: z.enum([allJobRythm[0], ...allJobRythm.slice(1)]).describe("Répartition de la présence de l'alternant en formation/entreprise"), custom_address: z.string().nullish().describe("Adresse personnalisée de l'entreprise"), custom_geo_coordinates: z.string().nullish().describe("Latitude/Longitude de l'adresse personnalisée de l'entreprise"), custom_job_title: z.string().nullish().describe("Titre personnalisée de l'offre"), diff --git a/shared/routes/jobOpportunity.routes.ts b/shared/routes/jobOpportunity.routes.ts new file mode 100644 index 0000000000..d236b574fe --- /dev/null +++ b/shared/routes/jobOpportunity.routes.ts @@ -0,0 +1,26 @@ +import { NIVEAUX_POUR_LBA, OPCOS } from "../constants/recruteur" +import { extensions } from "../helpers/zodHelpers/zodPrimitives" +import { z } from "../helpers/zodWithOpenApi" + +const romes = z.array(extensions.romeCode()) +const rncp = extensions.rncpCode() +const insee = extensions.inseeCode() + +const zJobOpportunityQuerystringBase = z.object({ + latitude: extensions.latitude(), + longitude: extensions.longitude(), + radius: z.number().min(0).max(200).default(10), + diploma: extensions.buildEnum(NIVEAUX_POUR_LBA).default(NIVEAUX_POUR_LBA.INDIFFERENT), + opco: extensions.buildEnum(OPCOS).optional(), + opcoUrl: z.string().optional(), +}) + +export const zJobOpportunityRome = zJobOpportunityQuerystringBase.extend({ romes }).strict() +export const zJobOpportunityRncp = zJobOpportunityQuerystringBase.extend({ rncp }).strict() +export type IJobOpportunityRome = z.output +export type IJobOpportunityRncp = z.output + +export const zJobQuerystringFranceTravailRome = zJobOpportunityQuerystringBase.extend({ romes, insee }).strict() +export const zJobQuerystringFranceTravailRncp = zJobOpportunityQuerystringBase.extend({ rncp, insee }).strict() +export type IJobOpportunityFranceTravailRome = z.output +export type IJobOpportunityFranceTravailRncp = z.output diff --git a/shared/routes/jobs.routes.v2.ts b/shared/routes/jobs.routes.v2.ts index 4a3700cad7..bfe09775e2 100644 --- a/shared/routes/jobs.routes.v2.ts +++ b/shared/routes/jobs.routes.v2.ts @@ -1,6 +1,7 @@ import { LBA_ITEM_TYPE } from "../constants/lbaitem" import { extensions } from "../helpers/zodHelpers/zodPrimitives" import { z } from "../helpers/zodWithOpenApi" +import { zJobOpportunity } from "../interface/jobOpportunity.types" import { ZJob, ZJobFields, ZJobStartDateCreate } from "../models" import { zObjectId } from "../models/common" import { ZApiError, ZLbacError, ZLbarError } from "../models/lbacError.model" @@ -23,6 +24,7 @@ import { zSourcesParams, } from "./_params" import { IRoutesDef, ZResError } from "./common.routes" +import { zJobOpportunityRncp, zJobOpportunityRome, zJobQuerystringFranceTravailRncp, zJobQuerystringFranceTravailRome } from "./jobOpportunity.routes" export const zJobsRoutesV2 = { get: { @@ -398,6 +400,110 @@ export const zJobsRoutesV2 = { })}`, }, }, + "/jobs/rome/recruteurs_lba": { + method: "get", + path: "/jobs/rome/recruteurs_lba", + querystring: zJobOpportunityRome, + response: { + "200": z.array(zJobOpportunity), + }, + securityScheme: { + auth: "api-apprentissage", + access: null, + resources: {}, + }, + }, + "/jobs/rncp/recruteurs_lba": { + method: "get", + path: "/jobs/rncp/recruteurs_lba", + querystring: zJobOpportunityRncp, + response: { + "200": z.array(zJobOpportunity), + }, + securityScheme: { + auth: "api-apprentissage", + access: null, + resources: {}, + }, + }, + "/jobs/rome/offres_emploi_lba": { + method: "get", + path: "/jobs/rome/offres_emploi_lba", + querystring: zJobOpportunityRome, + response: { + "200": z.array(zJobOpportunity), + }, + securityScheme: { + auth: "api-apprentissage", + access: null, + resources: {}, + }, + }, + "/jobs/rncp/offres_emploi_lba": { + method: "get", + path: "/jobs/rncp/offres_emploi_lba", + querystring: zJobOpportunityRncp, + response: { + "200": z.array(zJobOpportunity), + }, + securityScheme: { + auth: "api-apprentissage", + access: null, + resources: {}, + }, + }, + "/jobs/rome/offres_emploi_partenaires": { + method: "get", + path: "/jobs/rome/offres_emploi_partenaires", + querystring: zJobOpportunityRome, + response: { + "200": z.array(zJobOpportunity), + }, + securityScheme: { + auth: "api-apprentissage", + access: null, + resources: {}, + }, + }, + "/jobs/rncp/offres_emploi_partenaires": { + method: "get", + path: "/jobs/rncp/offres_emploi_partenaires", + querystring: zJobOpportunityRncp, + response: { + "200": z.array(zJobOpportunity), + }, + securityScheme: { + auth: "api-apprentissage", + access: null, + resources: {}, + }, + }, + "/jobs/rome/offres_emploi_france_travail": { + method: "get", + path: "/jobs/rome/offres_emploi_france_travail", + querystring: zJobQuerystringFranceTravailRome, + response: { + "200": z.array(zJobOpportunity), + }, + securityScheme: { + auth: "api-apprentissage", + access: null, + resources: {}, + }, + }, + "/jobs/rncp/offres_emploi_france_travail": { + method: "get", + path: "/jobs/rncp/offres_emploi_france_travail", + querystring: zJobQuerystringFranceTravailRncp, + response: { + "200": z.array(zJobOpportunity), + }, + securityScheme: { + auth: "api-apprentissage", + access: null, + resources: {}, + }, + }, }, post: { "/jobs/establishment": {