diff --git a/.talismanrc b/.talismanrc index 9cb8bad726..b126e61263 100644 --- a/.talismanrc +++ b/.talismanrc @@ -61,12 +61,16 @@ fileignoreconfig: checksum: f77ba0fa55f1cbd5b708fdc9f0d5b229e84a1de6c433186d96aa94a7f6388f0c - filename: server/tests/unit/security/accessTokenService.test.ts checksum: 232b1bae52a4d4f961637f59b09da5e480147864c76980a2b86e77a73bb36923 +- filename: server/tests/unit/security/accessTokenService.test.ts + checksum: 58153e7e57ef450bfbf061f322cd8b7643dba12513b183194b4eafdb166f58d3 - filename: server/tests/unit/security/authorisationService.test.ts checksum: 581074420be582973bbfcdfafe1f700ca32f56e331911609cdc1cb2fb2626383 - filename: shared/constants/recruteur.ts checksum: 28af032d2eb26aec7dd3bb1d32253f992a036626c36a92eb1e7ff07599fd0b2b - filename: shared/helpers/generateUri.ts checksum: 03ecb8627c19374e97450e5974ca6a5f51e269a8bb1cf5d372a8c2a2aca72cfa +- filename: shared/helpers/generateUri.ts + checksum: 6542db0d3eca959c6e81d574f8b71d4b18d2f1af21042ca5ed4dff319cd39555 - filename: shared/helpers/openapi/__snapshots__/generateOpenapi.test.ts.snap checksum: 9358c7f8155efcfc5687be3f01ae3516a05988118304c124a3358094aa966363 - filename: shared/helpers/openapi/generateOpenapi.test.ts diff --git a/server/src/http/routes/appointmentRequest.controller.ts b/server/src/http/routes/appointmentRequest.controller.ts index fdd5a5f878..07d2b64cfa 100644 --- a/server/src/http/routes/appointmentRequest.controller.ts +++ b/server/src/http/routes/appointmentRequest.controller.ts @@ -3,6 +3,7 @@ import Joi from "joi" import { zRoutes } from "shared/index" import { getStaticFilePath } from "@/common/utils/getStaticFilePath" +import { createRdvaAppointmentIdPageLink } from "@/services/appLinks.service" import { mailType } from "../../common/model/constants/appointments" import { getReferrerByKeyName } from "../../common/model/constants/referrers" @@ -95,6 +96,10 @@ export default (server: Server) => { }), ]) + if (!etablissement?.formateur_siret) { + throw new Error("Etablissement formateur_siret not found") + } + const mailData = { appointmentId: createdAppointement._id, user: { @@ -118,7 +123,6 @@ export default (server: Server) => { reasons: createdAppointement.applicant_reasons, referrerLink: referrerObj.url, appointment_origin: referrerObj.full_name, - link: `${config.publicUrl}/espace-pro/establishment/${etablissement?._id}/appointments/${createdAppointement._id}?utm_source=mail`, }, images: { logoLba: `${config.publicUrl}/images/emails/logo_LBA.png?raw=true`, @@ -142,11 +146,13 @@ export default (server: Server) => { data: mailData, }), mailer.sendEmail({ - // TODO to check string | null to: eligibleTrainingsForAppointment.lieu_formation_email, subject: emailCfaSubject, template: getStaticFilePath("./templates/mail-cfa-demande-de-contact.mjml.ejs"), - data: mailData, + data: { + ...mailData, + link: createRdvaAppointmentIdPageLink(eligibleTrainingsForAppointment.lieu_formation_email, etablissement.formateur_siret, etablissement._id, createdAppointement._id), + }, }), ]) @@ -189,10 +195,58 @@ export default (server: Server) => { } ) + server.get( + "/appointment-request/context/short-recap", + { + schema: zRoutes.get["/appointment-request/context/short-recap"], + }, + async (req, res) => { + const { appointmentId } = req.query + + const appointment = await Appointment.findById(appointmentId, { cle_ministere_educatif: 1, applicant_id: 1 }).lean() + + if (!appointment) { + throw Boom.notFound() + } + + const [etablissement, user] = await Promise.all([ + EligibleTrainingsForAppointment.findOne( + { cle_ministere_educatif: appointment.cle_ministere_educatif }, + { + etablissement_formateur_raison_sociale: 1, + lieu_formation_email: 1, + _id: 0, + } + ).lean(), + User.findById(appointment.applicant_id, { + lastname: 1, + firstname: 1, + phone: 1, + email: 1, + _id: 0, + }).lean(), + ]) + + if (!etablissement) { + throw Boom.internal("Etablissment not found") + } + + if (!user) { + throw Boom.internal("User not found") + } + + res.status(200).send({ + user, + etablissement, + }) + } + ) + server.get( "/appointment-request/context/recap", { schema: zRoutes.get["/appointment-request/context/recap"], + onRequest: [server.auth(zRoutes.get["/appointment-request/context/recap"])], }, async (req, res) => { const { appointmentId } = req.query @@ -252,6 +306,7 @@ export default (server: Server) => { "/appointment-request/reply", { schema: zRoutes.post["/appointment-request/reply"], + onRequest: [server.auth(zRoutes.post["/appointment-request/reply"])], }, async (req, res) => { await appointmentReplySchema.validateAsync(req.body, { abortEarly: false }) @@ -261,12 +316,15 @@ export default (server: Server) => { if (!appointment) throw Boom.notFound() + if (!appointment.applicant_id) { + throw Boom.internal("Applicant id not found.") + } + const [eligibleTrainingsForAppointment, user] = await Promise.all([ eligibleTrainingsForAppointmentService.getParameterByCleMinistereEducatif({ cleMinistereEducatif: appointment.cle_ministere_educatif, }), - // TODO applicant_id null | undefined | string - users.getUserById(appointment.applicant_id as string), + users.getUserById(appointment.applicant_id), ]) if (!user || !eligibleTrainingsForAppointment) throw Boom.notFound() diff --git a/server/src/http/routes/etablissement.controller.ts b/server/src/http/routes/etablissement.controller.ts index 2711dad11d..e522f21152 100644 --- a/server/src/http/routes/etablissement.controller.ts +++ b/server/src/http/routes/etablissement.controller.ts @@ -39,6 +39,7 @@ export default (server: Server) => { "/etablissements/:id", { schema: zRoutes.get["/etablissements/:id"], + onRequest: [server.auth(zRoutes.get["/etablissements/:id"])], }, async (req, res) => { const etablissement = await Etablissement.findById(req.params.id, etablissementProjection).lean() @@ -58,6 +59,7 @@ export default (server: Server) => { "/etablissements/:id/premium/affelnet/accept", { schema: zRoutes.post["/etablissements/:id/premium/affelnet/accept"], + onRequest: [server.auth(zRoutes.post["/etablissements/:id/premium/affelnet/accept"])], }, async (req, res) => { const etablissement = await Etablissement.findById(req.params.id) @@ -179,9 +181,10 @@ export default (server: Server) => { "/etablissements/:id/premium/accept", { schema: zRoutes.post["/etablissements/:id/premium/accept"], + onRequest: [server.auth(zRoutes.post["/etablissements/:id/premium/accept"])], }, async (req, res) => { - const etablissement = await Etablissement.findById(req.params.id) + const etablissement = await Etablissement.findById(req.params.id).lean() if (!etablissement) { throw Boom.badRequest("Etablissement not found.") @@ -279,7 +282,7 @@ export default (server: Server) => { ) const [result] = await Promise.all([ - Etablissement.findById(req.params.id), + Etablissement.findById(req.params.id).lean(), ...eligibleTrainingsForAppointmentsParcoursupFound.map((eligibleTrainingsForAppointment) => eligibleTrainingsForAppointmentService.update( { _id: eligibleTrainingsForAppointment._id, lieu_formation_email: { $nin: [null, ""] } }, @@ -303,6 +306,7 @@ export default (server: Server) => { "/etablissements/:id/premium/affelnet/refuse", { schema: zRoutes.post["/etablissements/:id/premium/affelnet/refuse"], + onRequest: [server.auth(zRoutes.post["/etablissements/:id/premium/affelnet/refuse"])], }, async (req, res) => { const etablissement = await Etablissement.findById(req.params.id) @@ -376,6 +380,7 @@ export default (server: Server) => { "/etablissements/:id/premium/refuse", { schema: zRoutes.post["/etablissements/:id/premium/refuse"], + onRequest: [server.auth(zRoutes.post["/etablissements/:id/premium/refuse"])], }, async (req, res) => { const etablissement = await Etablissement.findById(req.params.id) @@ -449,6 +454,7 @@ export default (server: Server) => { "/etablissements/:id/appointments/:appointmentId", { schema: zRoutes.patch["/etablissements/:id/appointments/:appointmentId"], + onRequest: [server.auth(zRoutes.patch["/etablissements/:id/appointments/:appointmentId"])], }, async ({ body, params }, res) => { const { has_been_read } = body diff --git a/server/src/jobs/rdv/inviteEtablissementToOptOut.ts b/server/src/jobs/rdv/inviteEtablissementToOptOut.ts index bee22773e6..e6bb3dc600 100644 --- a/server/src/jobs/rdv/inviteEtablissementToOptOut.ts +++ b/server/src/jobs/rdv/inviteEtablissementToOptOut.ts @@ -1,6 +1,7 @@ import * as _ from "lodash-es" import { getStaticFilePath } from "@/common/utils/getStaticFilePath" +import { createRdvaOptOutUnsubscribePageLink } from "@/services/appLinks.service" import { logger } from "../../common/logger" import { mailType } from "../../common/model/constants/etablissement" @@ -68,7 +69,7 @@ export const inviteEtablissementToOptOut = async () => { } // Invite all etablissements only in production environment, for etablissement that have an "email_decisionnaire" - if (emailDecisionaire) { + if (emailDecisionaire && etablissement.gestionnaire_email && etablissement.formateur_siret) { const willBeActivatedAt = dayjs().add(15, "days") const { messageId } = await mailer.sendEmail({ @@ -89,7 +90,7 @@ export const inviteEtablissementToOptOut = async () => { formateur_city: etablissement.formateur_city, siret: etablissement?.formateur_siret, optOutActivatedAtDate: willBeActivatedAt.format("DD/MM"), - linkToUnsubscribe: `${config.publicUrl}/espace-pro/form/opt-out/unsubscribe/${etablissement._id}`, + linkToUnsubscribe: createRdvaOptOutUnsubscribePageLink(etablissement.gestionnaire_email, etablissement.formateur_siret, etablissement._id.toString()), }, user: { destinataireEmail: emailDecisionaire, diff --git a/server/src/jobs/rdv/inviteEtablissementToPremium.ts b/server/src/jobs/rdv/inviteEtablissementToPremium.ts index c1e6a30647..02d130fcd3 100644 --- a/server/src/jobs/rdv/inviteEtablissementToPremium.ts +++ b/server/src/jobs/rdv/inviteEtablissementToPremium.ts @@ -1,5 +1,6 @@ import { getStaticFilePath } from "@/common/utils/getStaticFilePath" import { isValidEmail } from "@/common/utils/isValidEmail" +import { createRdvaPremiumParcoursupPageLink } from "@/services/appLinks.service" import { logger } from "../../common/logger" import { mailType } from "../../common/model/constants/etablissement" @@ -15,10 +16,10 @@ import mailer from "../../services/mailer.service" export const inviteEtablissementToPremium = async () => { logger.info("Cron #inviteEtablissementToPremium started.") - const startInvitationPeriod = dayjs().month(0).date(1) + const startInvitationPeriod = dayjs().month(0).date(8) const endInvitationPeriod = dayjs().month(7).date(31) if (!dayjs().isBetween(startInvitationPeriod, endInvitationPeriod, "day", "[]")) { - logger.info("Stopped because we are not between the 01/01 and the 31/08 (eligible period).") + logger.info("Stopped because we are not between the 08/01 and the 31/08 (eligible period).") return } @@ -28,7 +29,9 @@ export const inviteEtablissementToPremium = async () => { }, premium_activation_date: null, "to_etablissement_emails.campaign": { $ne: mailType.PREMIUM_INVITE }, - }) + }).lean() + + logger.info("Cron #inviteEtablissementToPremium / Etablissement: ", etablissementsToInvite.length) for (const etablissement of etablissementsToInvite) { // Only send an invite if the "etablissement" have at least one available Parcoursup "formation" @@ -38,7 +41,7 @@ export const inviteEtablissementToPremium = async () => { parcoursup_id: { $ne: null }, }).lean() - if (!hasOneAvailableFormation || !isValidEmail(etablissement.gestionnaire_email)) { + if (!hasOneAvailableFormation || !isValidEmail(etablissement.gestionnaire_email) || !etablissement.formateur_siret) { continue } @@ -56,7 +59,7 @@ export const inviteEtablissementToPremium = async () => { etablissement: { email: etablissement.gestionnaire_email, activatedAt: dayjs(etablissement.optout_activation_scheduled_date).format("DD/MM"), - linkToForm: `${config.publicUrl}/espace-pro/form/premium/${etablissement._id}`, + linkToForm: createRdvaPremiumParcoursupPageLink(etablissement.gestionnaire_email, etablissement.formateur_siret, etablissement._id.toString()), }, }, }) diff --git a/server/src/jobs/rdv/inviteEtablissementToPremiumAffelnet.ts b/server/src/jobs/rdv/inviteEtablissementToPremiumAffelnet.ts index 6e16dcee06..287d01101a 100644 --- a/server/src/jobs/rdv/inviteEtablissementToPremiumAffelnet.ts +++ b/server/src/jobs/rdv/inviteEtablissementToPremiumAffelnet.ts @@ -1,4 +1,5 @@ import { getStaticFilePath } from "@/common/utils/getStaticFilePath" +import { createRdvaPremiumAffelnetPageLink } from "@/services/appLinks.service" import { logger } from "../../common/logger" import { mailType } from "../../common/model/constants/etablissement" @@ -27,7 +28,7 @@ export const inviteEtablissementAffelnetToPremium = async () => { }) for (const etablissement of etablissementToInvite) { - if (!etablissement.gestionnaire_email) { + if (!etablissement.gestionnaire_email || !etablissement.formateur_siret) { continue } @@ -45,7 +46,7 @@ export const inviteEtablissementAffelnetToPremium = async () => { etablissement: { email: etablissement.gestionnaire_email, activatedAt: dayjs(etablissement.created_at).format("DD/MM"), - linkToForm: `${config.publicUrl}/espace-pro/form/premium/affelnet/${etablissement._id}`, + linkToForm: createRdvaPremiumAffelnetPageLink(etablissement.gestionnaire_email, etablissement.formateur_siret, etablissement._id.toString()), }, }, }) diff --git a/server/src/jobs/rdv/inviteEtablissementToPremiumFollowUp.ts b/server/src/jobs/rdv/inviteEtablissementToPremiumFollowUp.ts index a39be6642d..45a228bb76 100644 --- a/server/src/jobs/rdv/inviteEtablissementToPremiumFollowUp.ts +++ b/server/src/jobs/rdv/inviteEtablissementToPremiumFollowUp.ts @@ -1,4 +1,5 @@ import { getStaticFilePath } from "@/common/utils/getStaticFilePath" +import { createRdvaPremiumParcoursupPageLink } from "@/services/appLinks.service" import { logger } from "../../common/logger" import { mailType } from "../../common/model/constants/etablissement" @@ -35,7 +36,7 @@ export const inviteEtablissementToPremiumFollowUp = async () => { }) for (const etablissement of etablissementsFound) { - if (!etablissement.gestionnaire_email || !isValidEmail(etablissement.gestionnaire_email)) { + if (!etablissement.gestionnaire_email || !isValidEmail(etablissement.gestionnaire_email) || !etablissement.formateur_siret) { continue } @@ -54,7 +55,7 @@ export const inviteEtablissementToPremiumFollowUp = async () => { etablissement: { email: etablissement.gestionnaire_email, activatedAt: dayjs(etablissement.optout_activation_scheduled_date).format("DD/MM"), - linkToForm: `${config.publicUrl}/espace-pro/form/premium/${etablissement._id}`, + linkToForm: createRdvaPremiumParcoursupPageLink(etablissement.gestionnaire_email, etablissement.formateur_siret, etablissement._id.toString()), }, }, }) diff --git a/server/src/jobs/rdv/inviteEtablissementToPremiumFollowUpAffelnet.ts b/server/src/jobs/rdv/inviteEtablissementToPremiumFollowUpAffelnet.ts index 829e0940af..06f293d9f2 100644 --- a/server/src/jobs/rdv/inviteEtablissementToPremiumFollowUpAffelnet.ts +++ b/server/src/jobs/rdv/inviteEtablissementToPremiumFollowUpAffelnet.ts @@ -1,4 +1,5 @@ import { getStaticFilePath } from "@/common/utils/getStaticFilePath" +import { createRdvaPremiumAffelnetPageLink } from "@/services/appLinks.service" import { logger } from "../../common/logger" import { mailType } from "../../common/model/constants/etablissement" @@ -32,7 +33,7 @@ export const inviteEtablissementAffelnetToPremiumFollowUp = async () => { }) for (const etablissement of etablissementsFound) { - if (!etablissement.gestionnaire_email || !isValidEmail(etablissement.gestionnaire_email)) { + if (!etablissement.gestionnaire_email || !isValidEmail(etablissement.gestionnaire_email) || !etablissement.formateur_siret) { continue } @@ -51,7 +52,7 @@ export const inviteEtablissementAffelnetToPremiumFollowUp = async () => { etablissement: { email: etablissement.gestionnaire_email, activatedAt: dayjs(etablissement.created_at).format("DD/MM"), - linkToForm: `${config.publicUrl}/espace-pro/form/premium/affelnet/${etablissement._id}`, + linkToForm: createRdvaPremiumAffelnetPageLink(etablissement.gestionnaire_email, etablissement.formateur_siret, etablissement._id.toString()), }, }, }) diff --git a/server/src/services/appLinks.service.ts b/server/src/services/appLinks.service.ts index 9ef44fdb91..f9f753f053 100644 --- a/server/src/services/appLinks.service.ts +++ b/server/src/services/appLinks.service.ts @@ -85,3 +85,169 @@ export function createCfaUnsubscribeToken(email: string, siret: string) { } ) } + +/** + * Forge a link for Affelnet premium activation. + */ +export function createRdvaPremiumAffelnetPageLink(email: string, siret: string, etablissementId: string): string { + const token = generateAccessToken( + { type: "cfa", email, siret }, + [ + generateScope({ + schema: zRoutes.get["/etablissements/:id"], + options: { + params: { id: etablissementId }, + querystring: undefined, + }, + resources: { + etablissement: [etablissementId], + }, + }), + generateScope({ + schema: zRoutes.post["/etablissements/:id/premium/affelnet/accept"], + options: { + params: { id: etablissementId }, + querystring: undefined, + }, + resources: { + etablissement: [etablissementId], + }, + }), + generateScope({ + schema: zRoutes.post["/etablissements/:id/premium/affelnet/refuse"], + options: { + params: { id: etablissementId }, + querystring: undefined, + }, + resources: { + etablissement: [etablissementId], + }, + }), + ], + { + expiresIn: "30d", + } + ) + + return `${config.publicUrl}/espace-pro/form/premium/affelnet/${etablissementId}?token=${encodeURIComponent(token)}` +} + +/** + * Forge a link for Parcoursup premium activation. + */ +export function createRdvaPremiumParcoursupPageLink(email: string, siret: string, etablissementId: string): string { + const token = generateAccessToken( + { type: "cfa", email, siret }, + [ + generateScope({ + schema: zRoutes.get["/etablissements/:id"], + options: { + params: { id: etablissementId }, + querystring: undefined, + }, + resources: { + etablissement: [etablissementId], + }, + }), + generateScope({ + schema: zRoutes.post["/etablissements/:id/premium/accept"], + options: { + params: { id: etablissementId }, + querystring: undefined, + }, + resources: { + etablissement: [etablissementId], + }, + }), + generateScope({ + schema: zRoutes.post["/etablissements/:id/premium/refuse"], + options: { + params: { id: etablissementId }, + querystring: undefined, + }, + resources: { + etablissement: [etablissementId], + }, + }), + ], + { + expiresIn: "30d", + } + ) + + return `${config.publicUrl}/espace-pro/form/premium/${etablissementId}?token=${encodeURIComponent(token)}` +} + +/** + * Forge a link for allwoing unsubscription. + */ +export function createRdvaOptOutUnsubscribePageLink(email: string, siret: string, etablissementId: string): string { + const token = generateAccessToken( + { type: "cfa", email, siret }, + [ + generateScope({ + schema: zRoutes.get["/etablissements/:id/opt-out/unsubscribe"], + options: { + params: { id: etablissementId }, + querystring: undefined, + }, + resources: { + etablissement: [etablissementId], + }, + }), + ], + { + expiresIn: "30d", + } + ) + return `${config.publicUrl}/espace-pro/form/opt-out/unsubscribe/${etablissementId}?token=${encodeURIComponent(token)}` +} + +/** + * Forge a link for reading appointment + */ +export function createRdvaAppointmentIdPageLink(email: string, siret: string, etablissementId: string, appointmentId: string): string { + const token = generateAccessToken( + { type: "cfa", email, siret }, + [ + generateScope({ + schema: zRoutes.patch["/etablissements/:id/appointments/:appointmentId"], + options: { + params: { id: etablissementId, appointmentId }, + querystring: undefined, + }, + resources: { + etablissement: [etablissementId], + appointment: [appointmentId], + }, + }), + generateScope({ + schema: zRoutes.get["/appointment-request/context/recap"], + options: { + params: undefined, + querystring: { + appointmentId, + }, + }, + resources: { + appointment: [appointmentId], + }, + }), + generateScope({ + schema: zRoutes.post["/appointment-request/reply"], + options: { + params: undefined, + querystring: undefined, + }, + resources: { + appointment: [appointmentId], + }, + }), + ], + { + expiresIn: "30d", + } + ) + + return `${config.publicUrl}/espace-pro/establishment/${etablissementId}/appointments/${appointmentId}?token=${encodeURIComponent(token)}` +} diff --git a/server/static/templates/mail-cfa-demande-de-contact.mjml.ejs b/server/static/templates/mail-cfa-demande-de-contact.mjml.ejs index d9dc8e7740..dd1e07787f 100644 --- a/server/static/templates/mail-cfa-demande-de-contact.mjml.ejs +++ b/server/static/templates/mail-cfa-demande-de-contact.mjml.ejs @@ -24,7 +24,7 @@ <%= data.user.firstname %> <%= data.user.lastname %> souhaite des renseignements sur votre formation. - + Voir les coordonnées du contact diff --git a/shared/helpers/openapi/__snapshots__/generateOpenapi.test.ts.snap b/shared/helpers/openapi/__snapshots__/generateOpenapi.test.ts.snap index a836603bc8..d898ccd037 100644 --- a/shared/helpers/openapi/__snapshots__/generateOpenapi.test.ts.snap +++ b/shared/helpers/openapi/__snapshots__/generateOpenapi.test.ts.snap @@ -2849,7 +2849,7 @@ exports[`generateOpenApiSchema > should generate proper schema 1`] = ` "required": true, }, "responses": { - "2xx": { + "200": { "content": { "application/json": { "schema": { diff --git a/shared/routes/appointments.routes.ts b/shared/routes/appointments.routes.ts index a9f00318d4..7ac6dde8c1 100644 --- a/shared/routes/appointments.routes.ts +++ b/shared/routes/appointments.routes.ts @@ -119,7 +119,7 @@ export const zAppointmentsRoute = { method: "get", path: "/admin/appointments/details", response: { - "2xx": z + "200": z .object({ appointments: z.array( z @@ -155,13 +155,38 @@ export const zAppointmentsRoute = { ressources: {}, }, }, + "/appointment-request/context/short-recap": { + method: "get", + path: "/appointment-request/context/short-recap", + querystring: z.object({ appointmentId: z.string() }).strict(), + response: { + "200": z + .object({ + user: z + .object({ + firstname: z.string(), + lastname: z.string(), + phone: z.string(), + email: z.string(), + }) + .strict(), + etablissement: z + .object({ + etablissement_formateur_raison_sociale: z.string().nullish(), + lieu_formation_email: z.string().nullish(), + }) + .strict(), + }) + .strict(), + }, + securityScheme: null, + }, "/appointment-request/context/recap": { method: "get", path: "/appointment-request/context/recap", - // TODO_SECURITY_FIX il faut un secure token querystring: z.object({ appointmentId: z.string() }).strict(), response: { - "2xx": z + "200": z .object({ appointment: z .object({ @@ -199,7 +224,11 @@ export const zAppointmentsRoute = { }) .strict(), }, - securityScheme: null, + securityScheme: { + auth: "access-token", + access: null, + ressources: {}, + }, }, }, post: { @@ -208,7 +237,7 @@ export const zAppointmentsRoute = { path: "/appointment-request/context/create", body: zContextCreateSchema, response: { - "2xx": zAppointmentRequestContextCreateResponseSchema, + "200": zAppointmentRequestContextCreateResponseSchema, "404": z.union([ZResError, z.literal("Formation introuvable")]), "400": z.union([ZResError, z.literal("Critère de recherche non conforme.")]), }, @@ -237,8 +266,8 @@ export const zAppointmentsRoute = { .strict(), response: { // TODO ANY TO BE FIXED - "2xx": z.any(), - // "2xx": z + "200": z.any(), + // "200": z // .object({ // userId: z.string(), // appointment: z.union([ZAppointment, z.null()]), @@ -250,7 +279,6 @@ export const zAppointmentsRoute = { "/appointment-request/reply": { method: "post", path: "/appointment-request/reply", - // TODO_SECURITY_FIX token jwt body: z .object({ appointment_id: z.string(), @@ -260,7 +288,7 @@ export const zAppointmentsRoute = { }) .strict(), response: { - "2xx": z + "200": z .object({ appointment_id: z.string(), cfa_intention_to_applicant: z.string(), @@ -269,7 +297,11 @@ export const zAppointmentsRoute = { }) .strict(), }, - securityScheme: null, + securityScheme: { + auth: "access-token", + access: null, + ressources: {}, + }, }, }, } as const satisfies IRoutesDef diff --git a/shared/routes/etablissement.routes.ts b/shared/routes/etablissement.routes.ts index d423d5f0eb..41343c7bb6 100644 --- a/shared/routes/etablissement.routes.ts +++ b/shared/routes/etablissement.routes.ts @@ -1,6 +1,6 @@ import { extensions } from "../helpers/zodHelpers/zodPrimitives" import { z } from "../helpers/zodWithOpenApi" -import { ZEtablissement } from "../models" +import { ZAppointment, ZEtablissement } from "../models" import { zObjectId } from "../models/common" import { IRoutesDef } from "./common.routes" @@ -12,7 +12,7 @@ export const zEtablissementRoutes = { path: "/admin/etablissements/siret-formateur/:siret", params: z.object({ siret: extensions.siret }).strict(), response: { - "2xx": ZEtablissement.strict(), + "200": ZEtablissement.strict(), }, securityScheme: { auth: "cookie-session", @@ -31,7 +31,7 @@ export const zEtablissementRoutes = { path: "/admin/etablissements/:id", params: z.object({ id: zObjectId }).strict(), response: { - "2xx": ZEtablissement.strict(), + "200": ZEtablissement.strict(), }, securityScheme: { auth: "cookie-session", @@ -50,7 +50,7 @@ export const zEtablissementRoutes = { path: "/etablissements/:id", params: z.object({ id: zObjectId }).strict(), response: { - "2xx": ZEtablissement.pick({ + "200": ZEtablissement.pick({ _id: true, optout_refusal_date: true, raison_sociale: true, @@ -64,7 +64,11 @@ export const zEtablissementRoutes = { premium_refusal_date: true, }).strict(), }, - securityScheme: null, + securityScheme: { + auth: "access-token", + access: null, + ressources: {}, + }, }, }, post: { @@ -73,36 +77,52 @@ export const zEtablissementRoutes = { path: "/etablissements/:id/premium/affelnet/accept", params: z.object({ id: zObjectId }).strict(), response: { - "2xx": ZEtablissement, + "200": ZEtablissement, + }, + securityScheme: { + auth: "access-token", + access: null, + ressources: {}, }, - securityScheme: null, }, - "/etablissements/:id/premium/accept": { + "/etablissements/:id/premium/affelnet/refuse": { method: "post", - path: "/etablissements/:id/premium/accept", + path: "/etablissements/:id/premium/affelnet/refuse", params: z.object({ id: zObjectId }).strict(), response: { - "2xx": ZEtablissement, + "200": ZEtablissement, + }, + securityScheme: { + auth: "access-token", + access: null, + ressources: {}, }, - securityScheme: null, }, - "/etablissements/:id/premium/affelnet/refuse": { + "/etablissements/:id/premium/accept": { method: "post", - path: "/etablissements/:id/premium/affelnet/refuse", + path: "/etablissements/:id/premium/accept", params: z.object({ id: zObjectId }).strict(), response: { - "2xx": ZEtablissement, + "200": ZEtablissement, + }, + securityScheme: { + auth: "access-token", + access: null, + ressources: {}, }, - securityScheme: null, }, "/etablissements/:id/premium/refuse": { method: "post", path: "/etablissements/:id/premium/refuse", params: z.object({ id: zObjectId }).strict(), response: { - "2xx": ZEtablissement, + "200": ZEtablissement, + }, + securityScheme: { + auth: "access-token", + access: null, + ressources: {}, }, - securityScheme: null, }, "/etablissements/:id/opt-out/unsubscribe": { method: "post", @@ -111,7 +131,7 @@ export const zEtablissementRoutes = { params: z.object({ id: zObjectId }).strict(), body: z.union([z.object({ opt_out_question: z.string() }).strict(), z.object({}).strict()]), response: { - "2xx": ZEtablissement, + "200": ZEtablissement, }, securityScheme: null, }, @@ -125,7 +145,7 @@ export const zEtablissementRoutes = { gestionnaire_email: true, }).strict(), response: { - "2xx": ZEtablissement, + "200": ZEtablissement, }, securityScheme: { auth: "cookie-session", @@ -142,15 +162,16 @@ export const zEtablissementRoutes = { "/etablissements/:id/appointments/:appointmentId": { method: "patch", path: "/etablissements/:id/appointments/:appointmentId", - // TODO_SECURITY_FIX ajouter un jwt body: z.object({ has_been_read: z.boolean() }).strict(), params: z.object({ id: zObjectId, appointmentId: zObjectId }).strict(), response: { - // TODO ANY TO BE FIXED - "2xx": z.any(), - // "2xx": ZAppointment, + "200": ZAppointment, + }, + securityScheme: { + auth: "access-token", + access: null, + ressources: {}, }, - securityScheme: null, }, }, } as const satisfies IRoutesDef diff --git a/shared/security/permissions.ts b/shared/security/permissions.ts index a4ae1d9f7b..d5e5e7fcbb 100644 --- a/shared/security/permissions.ts +++ b/shared/security/permissions.ts @@ -61,6 +61,9 @@ export type AccessRessouces = { etablissement?: ReadonlyArray<{ _id: AccessResourcePath }> + appointment?: ReadonlyArray<{ + _id: AccessResourcePath + }> formationCatalogue?: ReadonlyArray<{ cle_ministere_educatif: AccessResourcePath }> diff --git a/ui/components/RDV/DemandeDeContact.tsx b/ui/components/RDV/DemandeDeContact.tsx index 9c601c6763..365d545a49 100644 --- a/ui/components/RDV/DemandeDeContact.tsx +++ b/ui/components/RDV/DemandeDeContact.tsx @@ -28,12 +28,12 @@ import { } from "@chakra-ui/react" import emailMisspelled, { top100 } from "email-misspelled" import { useFormik } from "formik" -import React, { useState } from "react" +import { useState } from "react" import { EApplicantType, EReasonsKey } from "shared" import { IAppointmentRequestContextCreateFormAvailableResponseSchema } from "shared/routes/appointments.routes" import * as Yup from "yup" -import { IAppointmentRequestRecapResponse, reasons } from "@/components/RDV/types" +import { reasons } from "@/components/RDV/types" import { BarberGuy } from "@/theme/components/icons" import { PaperPlane } from "@/theme/components/icons/PaperPlane" import { apiGet, apiPost } from "@/utils/api.utils" @@ -52,7 +52,7 @@ const DemandeDeContact = (props: Props) => { const [suggestedEmails, setSuggestedEmails] = useState([]) const [applicantReasons, setApplicantReasons] = useState(reasons) const [applicantType, setApplicantType] = useState(EApplicantType.ETUDIANT) - const [onSuccessSubmitResponse, setOnSuccessSubmitResponse] = useState(null) + const [onSuccessSubmitResponse, setOnSuccessSubmitResponse] = useState(null) const [error, setError] = useState(null) const emailChecker = emailMisspelled({ maxMisspelled: 3, domains: top100 }) @@ -113,7 +113,7 @@ const DemandeDeContact = (props: Props) => { }, })) as unknown as { appointment: { _id: string } } - const response = await apiGet("/appointment-request/context/recap", { + const response = await apiGet("/appointment-request/context/short-recap", { querystring: { appointmentId: appointment._id }, }) diff --git a/ui/components/RDV/types.ts b/ui/components/RDV/types.ts index 9055e7ce58..8599a0c9f2 100644 --- a/ui/components/RDV/types.ts +++ b/ui/components/RDV/types.ts @@ -30,6 +30,19 @@ type IAppointmentRequestRecapResponse = { } } +type IAppointmentRequestRecapResponseShort = { + user: { + firstname: string + lastname: string + phone: string + email: string + } + etablissement: { + etablissement_formateur_raison_sociale: string + lieu_formation_email: string + } +} + const reasons = [ { key: EReasonsKey.MODALITE, @@ -88,6 +101,6 @@ const reasons = [ }, ] -export type { IAppointmentRequestRecapResponse } +export type { IAppointmentRequestRecapResponse, IAppointmentRequestRecapResponseShort } export { reasons } diff --git a/ui/pages/espace-pro/admin/eligible-trainings-for-appointment/edit/[id].tsx b/ui/pages/espace-pro/admin/eligible-trainings-for-appointment/edit/[id].tsx index 0c9a5d70c8..f93cd84809 100644 --- a/ui/pages/espace-pro/admin/eligible-trainings-for-appointment/edit/[id].tsx +++ b/ui/pages/espace-pro/admin/eligible-trainings-for-appointment/edit/[id].tsx @@ -24,8 +24,8 @@ import { import emailValidator from "email-validator" import Head from "next/head" import { useRouter } from "next/router" -import React, { createRef, useEffect, useState } from "react" -import { IEtablissement } from "shared" +import { createRef, useEffect, useState } from "react" +import { IEtablissementJson } from "shared" import { referrers } from "shared/constants/referers" import { IEligibleTrainingsForAppointmentJson } from "shared/models/elligibleTraining.model" @@ -46,7 +46,7 @@ function EditPage() { const router = useRouter() const { id } = router.query const [eligibleTrainingsForAppointmentResult, setEligibleTrainingsForAppointmentResult] = useState([]) - const [etablissement, setEtablissement] = useState() + const [etablissement, setEtablissement] = useState() const [loading, setLoading] = useState(false) const toast = useToast() diff --git a/ui/pages/espace-pro/establishment/[establishmentId]/appointments/[appointmentId].tsx b/ui/pages/espace-pro/establishment/[establishmentId]/appointments/[appointmentId].tsx index af62dacdfc..20f5a953b8 100644 --- a/ui/pages/espace-pro/establishment/[establishmentId]/appointments/[appointmentId].tsx +++ b/ui/pages/espace-pro/establishment/[establishmentId]/appointments/[appointmentId].tsx @@ -20,7 +20,7 @@ import { CfaCandidatInformationUnreachable } from "../../../../../components/esp */ export default function CfaCandidatInformationPage() { const router = useRouter() - const { establishmentId, appointmentId } = router.query as { establishmentId: string; appointmentId: string } + const { establishmentId, appointmentId, token } = router.query as { establishmentId: string; appointmentId: string; token: string } const [data, setData] = useState(null) const [currentState, setCurrentState] = useState("initial") @@ -42,6 +42,9 @@ export default function CfaCandidatInformationPage() { cfa_message_to_applicant: values.message, cfa_message_to_applicant_date: formatDate(new Date()), }, + headers: { + authorization: `Bearer ${token}`, + }, }) setCurrentState("answered") @@ -58,6 +61,9 @@ export default function CfaCandidatInformationPage() { cfa_message_to_applicant: "", cfa_message_to_applicant_date: formatDate(new Date()), }, + headers: { + authorization: `Bearer ${token}`, + }, }) setCurrentState("other") } @@ -71,6 +77,9 @@ export default function CfaCandidatInformationPage() { cfa_message_to_applicant: "", cfa_message_to_applicant_date: formatDate(new Date()), }, + headers: { + authorization: `Bearer ${token}`, + }, }) setCurrentState("unreachable") } @@ -83,11 +92,20 @@ export default function CfaCandidatInformationPage() { const fetchData = async () => { if (appointmentId && establishmentId) { if (utmSource === "mail") { - await apiPatch("/etablissements/:id/appointments/:appointmentId", { params: { id: establishmentId, appointmentId }, body: { has_been_read: true } }) + await apiPatch("/etablissements/:id/appointments/:appointmentId", { + params: { id: establishmentId, appointmentId }, + body: { has_been_read: true }, + headers: { + authorization: `Bearer ${token}`, + }, + }) } const response = await apiGet("/appointment-request/context/recap", { querystring: { appointmentId }, + headers: { + authorization: `Bearer ${token}`, + }, }) setData(response) } diff --git a/ui/pages/espace-pro/form/index.tsx b/ui/pages/espace-pro/form/index.tsx index d4f17234af..a8efc50f5b 100644 --- a/ui/pages/espace-pro/form/index.tsx +++ b/ui/pages/espace-pro/form/index.tsx @@ -1,7 +1,7 @@ import { Box, Spinner, Text } from "@chakra-ui/react" import { useRouter } from "next/router" import { useEffect, useState } from "react" -import { IAppointmentRequestContextCreateResponseSchema, IAppointmentRequestContextCreateFormAvailableResponseSchema } from "shared/routes/appointments.routes" +import { IAppointmentRequestContextCreateFormAvailableResponseSchema, IAppointmentRequestContextCreateResponseSchema } from "shared/routes/appointments.routes" import { ContactCfaSummary } from "@/components/espace_pro/Candidat/layout/ContactCfaSummary" import DemandeDeContact from "@/components/RDV/DemandeDeContact" diff --git a/ui/pages/espace-pro/form/opt-out/unsubscribe/[id].tsx b/ui/pages/espace-pro/form/opt-out/unsubscribe/[id].tsx index 3cc11bab1f..831f7fa2a5 100644 --- a/ui/pages/espace-pro/form/opt-out/unsubscribe/[id].tsx +++ b/ui/pages/espace-pro/form/opt-out/unsubscribe/[id].tsx @@ -1,8 +1,8 @@ import { Box, Button, Container, Flex, Heading, Radio, RadioGroup, Stack, Text, Textarea } from "@chakra-ui/react" import Head from "next/head" import { useRouter } from "next/router" -import React, { useEffect, useState } from "react" -import { IEtablissement } from "shared" +import { useEffect, useState } from "react" +import { IEtablissementJson } from "shared" import { apiGet, apiPost } from "@/utils/api.utils" @@ -11,7 +11,7 @@ import Layout from "../../../../../components/espace_pro/common/components/Layou import { SuccessCircle } from "../../../../../theme/components/icons" type IEtablissementPartial = Pick< - IEtablissement, + IEtablissementJson, | "_id" | "optout_refusal_date" | "raison_sociale" @@ -36,7 +36,7 @@ export default function OptOutUnsubscribe() { } const router = useRouter() - const { id } = router.query as { id: string } + const { id, token } = router.query as { id: string; token: string } const [textarea, setTextarea] = useState("") const [hasBeenUnsubscribed, setHasBeenUnsubscribed] = useState(false) const [isQuestionSent, setIsQuestionSent] = useState(false) @@ -71,9 +71,12 @@ export default function OptOutUnsubscribe() { useEffect(() => { const fetchData = async () => { - const etablissement = (await apiGet("/etablissements/:id", { + const etablissement = await apiGet("/etablissements/:id", { params: { id }, - })) as any // TODO not any + headers: { + authorization: `Bearer ${token}`, + }, + }) if (etablissement.optout_refusal_date) { setHasBeenUnsubscribed(true) diff --git a/ui/pages/espace-pro/form/premium/[id].tsx b/ui/pages/espace-pro/form/premium/[id].tsx index fe9e2decf2..54115f7fef 100644 --- a/ui/pages/espace-pro/form/premium/[id].tsx +++ b/ui/pages/espace-pro/form/premium/[id].tsx @@ -1,7 +1,8 @@ import { Box, Button, Container, Flex, Stack, Text } from "@chakra-ui/react" import Head from "next/head" import { useRouter } from "next/router" -import React, { useEffect, useState } from "react" +import { useEffect, useState } from "react" +import { IEtablissementJson } from "shared" import { apiGet, apiPost } from "@/utils/api.utils" @@ -25,7 +26,7 @@ type IPremiumEtablissement = { */ export default function PremiumForm() { const router = useRouter() - const { id } = router.query as { id: string } + const { id, token } = router.query as { id: string; token: string } const [hasRefused, setHasRefused] = useState(false) const [hasAccepted, setHasAccepted] = useState(false) const [etablissement, setEtablissement]: [IPremiumEtablissement | null, (e: any) => void] = useState() @@ -39,6 +40,9 @@ export default function PremiumForm() { const accept = async () => { await apiPost("/etablissements/:id/premium/accept", { params: { id }, + headers: { + authorization: `Bearer ${token}`, + }, }) setHasAccepted(true) window.scrollTo(0, 0) @@ -51,6 +55,9 @@ export default function PremiumForm() { const refuse = async () => { await apiPost("/etablissements/:id/premium/refuse", { params: { id }, + headers: { + authorization: `Bearer ${token}`, + }, }) setHasRefused(true) window.scrollTo(0, 0) @@ -60,7 +67,10 @@ export default function PremiumForm() { const fetchData = async () => { const etablissement = (await apiGet("/etablissements/:id", { params: { id }, - })) as any // TODO not any + headers: { + authorization: `Bearer ${token}`, + }, + })) as IEtablissementJson if (etablissement.premium_refusal_date) { setHasRefused(true) diff --git a/ui/pages/espace-pro/form/premium/affelnet/[id].tsx b/ui/pages/espace-pro/form/premium/affelnet/[id].tsx index fd7f724b0c..3a2a9a95a7 100644 --- a/ui/pages/espace-pro/form/premium/affelnet/[id].tsx +++ b/ui/pages/espace-pro/form/premium/affelnet/[id].tsx @@ -1,7 +1,8 @@ import { Box, Button, Container, Flex, Stack, Text } from "@chakra-ui/react" import Head from "next/head" import { useRouter } from "next/router" -import React, { useEffect, useState } from "react" +import { useEffect, useState } from "react" +import { IEtablissementJson } from "shared" import { apiGet, apiPost } from "@/utils/api.utils" @@ -25,7 +26,7 @@ type IAffelnetEtablissement = { */ export default function PremiumAffelnetForm() { const router = useRouter() - const { id } = router.query as { id: string } + const { id, token } = router.query as { id: string; token: string } const [hasRefused, setHasRefused] = useState(false) const [hasAccepted, setHasAccepted] = useState(false) const [etablissement, setEtablissement]: [IAffelnetEtablissement | null, (e: any) => void] = useState() @@ -39,6 +40,9 @@ export default function PremiumAffelnetForm() { const accept = async () => { await apiPost("/etablissements/:id/premium/affelnet/accept", { params: { id }, + headers: { + authorization: `Bearer ${token}`, + }, }) setHasAccepted(true) @@ -52,6 +56,9 @@ export default function PremiumAffelnetForm() { const refuse = async () => { await apiPost("/etablissements/:id/premium/affelnet/refuse", { params: { id }, + headers: { + authorization: `Bearer ${token}`, + }, }) setHasRefused(true) window.scrollTo(0, 0) @@ -61,7 +68,10 @@ export default function PremiumAffelnetForm() { const fetchData = async () => { const etablissement = (await apiGet("/etablissements/:id", { params: { id }, - })) as any // TODO not any + headers: { + authorization: `Bearer ${token}`, + }, + })) as IEtablissementJson if (etablissement.premium_affelnet_refusal_date) { setHasRefused(true) diff --git a/ui/services/fetchPrdv.ts b/ui/services/fetchPrdv.ts index 69babbec24..4e2f1383b9 100644 --- a/ui/services/fetchPrdv.ts +++ b/ui/services/fetchPrdv.ts @@ -30,6 +30,7 @@ export default async function fetchPrdv(training, hasAlsoJob, _axios = axios, _w }, { headers: { "Content-Type": "application/json" } } ) + return response.data } catch (error) { if (error?.response?.data?.error === "Prise de rendez-vous non disponible." || error?.response?.data?.message === "Formation introuvable") { diff --git a/ui/utils/api.utils.ts b/ui/utils/api.utils.ts index 872232bc5f..fb9e6f28cc 100644 --- a/ui/utils/api.utils.ts +++ b/ui/utils/api.utils.ts @@ -1,5 +1,5 @@ import { IDeleteRoutes, IGetRoutes, IPatchRoutes, IPostRoutes, IPutRoutes, IRequest, IRequestFetchOptions, IResponse } from "shared" -import { generateUri, WithQueryStringAndPathParam, PathParam, QueryString } from "shared/helpers/generateUri" +import { PathParam, QueryString, WithQueryStringAndPathParam, generateUri } from "shared/helpers/generateUri" import { IResErrorJson, IRouteSchema, IRouteSchemaWrite } from "shared/routes/common.routes" import type { EmptyObject } from "type-fest" import z, { ZodType } from "zod"