From 660bb26990dfaca7fd7071330bc48345f2de1d76 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9my=20Auricoste?= Date: Tue, 24 Oct 2023 14:34:44 +0200 Subject: [PATCH 01/78] fix: wip --- .../schema/appointments/appointment.schema.ts | 7 + .../schema/multiCompte/buildMongooseModel.ts | 11 + .../model/schema/multiCompte/cfa.schema.ts | 46 +++ .../schema/multiCompte/entreprise.schema.ts | 58 ++++ .../multiCompte/roleManagement.schema.ts | 72 +++++ .../model/schema/multiCompte/user2.schema.ts | 84 +++++ .../common/model/schema/user/user.schema.ts | 2 +- server/src/common/utils/asyncUtils.ts | 7 + server/src/common/utils/enumUtils.ts | 12 + server/src/jobs/multiCompte/migrationUsers.ts | 299 ++++++++++++++++++ server/src/services/constant.service.ts | 24 +- shared/constants/appointment.ts | 4 + shared/models/appointments.model.ts | 3 + shared/models/cfa.model.ts | 23 +- shared/models/entreprise.model.ts | 25 ++ shared/models/enumToZod.ts | 6 + shared/models/roleManagement.model.ts | 48 +++ shared/models/user2.model.ts | 48 +++ 18 files changed, 758 insertions(+), 21 deletions(-) create mode 100644 server/src/common/model/schema/multiCompte/buildMongooseModel.ts create mode 100644 server/src/common/model/schema/multiCompte/cfa.schema.ts create mode 100644 server/src/common/model/schema/multiCompte/entreprise.schema.ts create mode 100644 server/src/common/model/schema/multiCompte/roleManagement.schema.ts create mode 100644 server/src/common/model/schema/multiCompte/user2.schema.ts create mode 100644 server/src/common/utils/enumUtils.ts create mode 100644 server/src/jobs/multiCompte/migrationUsers.ts create mode 100644 shared/constants/appointment.ts create mode 100644 shared/models/entreprise.model.ts create mode 100644 shared/models/enumToZod.ts create mode 100644 shared/models/roleManagement.model.ts create mode 100644 shared/models/user2.model.ts diff --git a/server/src/common/model/schema/appointments/appointment.schema.ts b/server/src/common/model/schema/appointments/appointment.schema.ts index f200b62e07..c634727c83 100644 --- a/server/src/common/model/schema/appointments/appointment.schema.ts +++ b/server/src/common/model/schema/appointments/appointment.schema.ts @@ -1,4 +1,5 @@ import { IAppointment } from "shared" +import { AppointmentUserType } from "shared/constants/appointment" import { model, Schema } from "../../../mongodb" import { mongoosePagination, Pagination } from "../_shared/mongoose-paginate" @@ -152,6 +153,12 @@ export const appointmentSchema = new Schema( default: false, description: "Si l'enregistrement est anonymisé", }, + applicant_user_type: { + type: String, + enum: [...Object.values(AppointmentUserType), null], + default: null, + description: "Role du demandeur : parent ou etudiant", + }, }, { versionKey: false, diff --git a/server/src/common/model/schema/multiCompte/buildMongooseModel.ts b/server/src/common/model/schema/multiCompte/buildMongooseModel.ts new file mode 100644 index 0000000000..bf28f9d21a --- /dev/null +++ b/server/src/common/model/schema/multiCompte/buildMongooseModel.ts @@ -0,0 +1,11 @@ +import mongoose, { Schema } from "mongoose" + +import { mongoosePagination, Pagination } from "../_shared/mongoose-paginate" + +const { model } = mongoose + +export const buildMongooseModel = (schema: Schema, tableName: string) => { + schema.plugin(mongoosePagination) + + return model>(tableName, schema) +} diff --git a/server/src/common/model/schema/multiCompte/cfa.schema.ts b/server/src/common/model/schema/multiCompte/cfa.schema.ts new file mode 100644 index 0000000000..27193e6015 --- /dev/null +++ b/server/src/common/model/schema/multiCompte/cfa.schema.ts @@ -0,0 +1,46 @@ +import { ICFA } from "shared/models/cfa.model.js" + +import { Schema } from "../../../mongodb.js" + +import { buildMongooseModel } from "./buildMongooseModel.js" + +const cfaSchema = new Schema( + { + establishment_siret: { + type: String, + description: "Siret de l'établissement", + }, + establishment_raison_sociale: { + type: String, + description: "Raison social de l'établissement", + }, + establishment_enseigne: { + type: String, + default: null, + description: "Enseigne de l'établissement", + }, + address_detail: { + type: Object, + description: "Detail de l'adresse de l'établissement", + }, + address: { + type: String, + description: "Adresse de l'établissement", + }, + geo_coordinates: { + type: String, + default: null, + description: "Latitude/Longitude de l'adresse de l'entreprise", + }, + origin: { + type: String, + description: "Origine de la creation (ex: Campagne mail, lien web, etc...) pour suivi", + }, + }, + { + timestamps: true, + versionKey: false, + } +) + +export const cfaRepository = buildMongooseModel(cfaSchema, "cfa") diff --git a/server/src/common/model/schema/multiCompte/entreprise.schema.ts b/server/src/common/model/schema/multiCompte/entreprise.schema.ts new file mode 100644 index 0000000000..dc3450402e --- /dev/null +++ b/server/src/common/model/schema/multiCompte/entreprise.schema.ts @@ -0,0 +1,58 @@ +import { IEntreprise } from "shared/models/entreprise.model.js" + +import { Schema } from "../../../mongodb.js" + +import { buildMongooseModel } from "./buildMongooseModel.js" + +const entrepriseSchema = new Schema( + { + establishment_siret: { + type: String, + description: "Siret de l'établissement", + }, + opco: { + type: String, + default: null, + description: "Information sur l'opco de l'entreprise", + }, + idcc: { + type: String, + description: "Identifiant convention collective de l'entreprise", + }, + establishment_raison_sociale: { + type: String, + description: "Raison social de l'établissement", + }, + establishment_enseigne: { + type: String, + default: null, + description: "Enseigne de l'établissement", + }, + address_detail: { + type: Object, + description: "Detail de l'adresse de l'établissement", + }, + address: { + type: String, + description: "Adresse de l'établissement", + }, + geo_coordinates: { + type: String, + default: null, + description: "Latitude/Longitude de l'adresse de l'entreprise", + }, + establishment_id: { + type: String, + description: "Si l'utilisateur est une entreprise, l'objet doit contenir un identifiant de formulaire unique", + }, + origin: { + type: String, + description: "Origine de la creation de l'utilisateur (ex: Campagne mail, lien web, etc...) pour suivi", + }, + }, + { + timestamps: true, + } +) + +export const entrepriseRepository = buildMongooseModel(entrepriseSchema, "entreprise") diff --git a/server/src/common/model/schema/multiCompte/roleManagement.schema.ts b/server/src/common/model/schema/multiCompte/roleManagement.schema.ts new file mode 100644 index 0000000000..40f19439b4 --- /dev/null +++ b/server/src/common/model/schema/multiCompte/roleManagement.schema.ts @@ -0,0 +1,72 @@ +import { AccessEntityType, AccessStatus, IRoleManagement, IRoleManagementEvent } from "shared/models/roleManagement.model.js" + +import { VALIDATION_UTILISATEUR } from "../../../../services/constant.service.js" +import { Schema } from "../../../mongodb.js" + +import { buildMongooseModel } from "./buildMongooseModel.js" + +const roleManagementEventSchema = new Schema( + { + validation_type: { + type: String, + enum: Object.values(VALIDATION_UTILISATEUR), + description: "Indique si l'action est ordonnée par un utilisateur ou le serveur", + }, + status: { + type: String, + enum: Object.values(AccessStatus), + description: "Statut de l'utilisateur", + }, + reason: { + type: String, + description: "Raison du changement de statut", + }, + granted_by: { + type: String, + default: null, + description: "Utilisateur à l'origine du changement", + }, + date: { + type: Date, + default: () => new Date(), + description: "Date de l'évènement", + }, + }, + { _id: false } +) + +const roleManagementSchema = new Schema( + { + accessor_id: { + type: String, + description: "ID de l'entité ayant accès", + }, + accessor_type: { + type: String, + enum: Object.values(AccessEntityType), + description: "Type de l'entité ayant accès", + }, + accessed_id: { + type: String, + description: "ID de l'entité sur laquelle l'accès est exercé", + }, + accessed_type: { + type: String, + enum: Object.values(AccessEntityType), + description: "Type de l'entité sur laquelle l'accès est exercé", + }, + history: { + type: [roleManagementEventSchema], + description: "Evénements liés au cycle de vie de l'accès", + }, + origin: { + type: String, + description: "Origine de la creation", + }, + }, + { + timestamps: true, + } +) + +export const roleManagementRepository = buildMongooseModel(roleManagementSchema, "roleManagement") diff --git a/server/src/common/model/schema/multiCompte/user2.schema.ts b/server/src/common/model/schema/multiCompte/user2.schema.ts new file mode 100644 index 0000000000..c89ca85a03 --- /dev/null +++ b/server/src/common/model/schema/multiCompte/user2.schema.ts @@ -0,0 +1,84 @@ +import { IUser2, IUserStatusEvent, UserEventType } from "shared/models/user2.model.js" + +import { VALIDATION_UTILISATEUR } from "../../../../services/constant.service.js" +import { Schema } from "../../../mongodb.js" + +import { buildMongooseModel } from "./buildMongooseModel.js" + +const userStatusEventSchema = new Schema( + { + validation_type: { + type: String, + enum: Object.values(VALIDATION_UTILISATEUR), + description: "Indique si l'action est ordonnée par un utilisateur ou le serveur", + }, + status: { + type: String, + enum: Object.values(UserEventType), + description: "Statut de l'utilisateur", + }, + reason: { + type: String, + description: "Raison du changement de statut", + }, + granted_by: { + type: String, + default: null, + description: "Utilisateur à l'origine du changement", + }, + date: { + type: Date, + default: () => new Date(), + description: "Date de l'évènement", + }, + }, + { _id: false } +) + +const User2Schema = new Schema( + { + firstname: { + type: String, + default: null, + description: "Le prénom", + }, + lastname: { + type: String, + default: null, + description: "Le nom", + }, + phone: { + type: String, + default: null, + description: "Le numéro de téléphone", + }, + email: { + type: String, + default: null, + description: "L'email", + }, + last_connection: { + type: Date, + default: null, + description: "Date de dernière connexion", + }, + is_anonymized: { + type: Boolean, + default: false, + description: "Si l'enregistrement est anonymisé", + }, + history: { + type: [userStatusEventSchema], + description: "Evénements liés au cycle de vie de l'utilisateur", + }, + origin: { + type: String, + description: "Origine de la creation de l'utilisateur (ex: Campagne mail, lien web, etc...) pour suivi", + }, + }, + { + timestamps: true, + } +) + +export const user2Repository = buildMongooseModel(User2Schema, "user2") diff --git a/server/src/common/model/schema/user/user.schema.ts b/server/src/common/model/schema/user/user.schema.ts index 9bdcfaa846..4f25ab3d88 100644 --- a/server/src/common/model/schema/user/user.schema.ts +++ b/server/src/common/model/schema/user/user.schema.ts @@ -44,7 +44,7 @@ export const userSchema = new Schema( role: { type: String, default: null, - description: "candidat | cfa | administrator", + description: "candidat | administrator", }, last_action_date: { type: Date, diff --git a/server/src/common/utils/asyncUtils.ts b/server/src/common/utils/asyncUtils.ts index 502389b0b6..d6acea50b9 100644 --- a/server/src/common/utils/asyncUtils.ts +++ b/server/src/common/utils/asyncUtils.ts @@ -4,6 +4,13 @@ export const asyncForEach = async (array: T[], callback: (item: T, index: num } } +export const asyncForEachGrouped = async (array: T[], groupSize: number, callback: (item: T, index: number) => Promise) => { + for (let index = 0; index < array.length; index += groupSize) { + const group = array.slice(index, index + groupSize) + await Promise.all(group.map((item, itemIndex) => callback(item, index + itemIndex))) + } +} + export const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) export function timeout(promise, millis) { diff --git a/server/src/common/utils/enumUtils.ts b/server/src/common/utils/enumUtils.ts new file mode 100644 index 0000000000..c5c2fa24bd --- /dev/null +++ b/server/src/common/utils/enumUtils.ts @@ -0,0 +1,12 @@ +export const parseEnum = (enumObj: Record, value: string | null): T | null => { + return Object.values(enumObj).find((enumValue) => enumValue.toLowerCase() === value?.toLowerCase()) ?? null +} +export const isEnum = (enumValues: Record, value: unknown): value is T => typeof value === "string" && parseEnum(enumValues, value) !== null + +export const parseEnumOrError = (enumObj: Record, value: string | null): T => { + const enumValue = parseEnum(enumObj, value) + if (enumValue === null) { + throw new Error(`could not parse ${value} as enum ${JSON.stringify(Object.values(enumObj))}`) + } + return enumValue +} diff --git a/server/src/jobs/multiCompte/migrationUsers.ts b/server/src/jobs/multiCompte/migrationUsers.ts new file mode 100644 index 0000000000..f09b8413c0 --- /dev/null +++ b/server/src/jobs/multiCompte/migrationUsers.ts @@ -0,0 +1,299 @@ +import dayjs from "dayjs" +import { AppointmentUserType } from "shared/constants/appointment.js" +import { ICFA } from "shared/models/cfa.model.js" +import { IEntreprise } from "shared/models/entreprise.model.js" +import { AccessEntityType, AccessStatus, IRoleManagement, IRoleManagementEvent } from "shared/models/roleManagement.model.js" +import { UserEventType, IUser2 } from "shared/models/user2.model.js" +import { IUserRecruteur } from "shared/models/usersRecruteur.model.js" + +import { logger } from "../../common/logger.js" +import { Appointment, User, UserRecruteur } from "../../common/model/index.js" +import { cfaRepository } from "../../common/model/schema/multiCompte/cfa.schema.js" +import { entrepriseRepository } from "../../common/model/schema/multiCompte/entreprise.schema.js" +import { roleManagementRepository } from "../../common/model/schema/multiCompte/roleManagement.schema.js" +import { user2Repository } from "../../common/model/schema/multiCompte/user2.schema.js" +import { asyncForEachGrouped } from "../../common/utils/asyncUtils.js" +import { parseEnumOrError } from "../../common/utils/enumUtils.js" +import { notifyToSlack } from "../../common/utils/slackUtils.js" +import { ENTREPRISE, ETAT_UTILISATEUR, OPCOS, VALIDATION_UTILISATEUR } from "../../services/constant.service.js" + +const migrationCandidats = async (now: Date) => { + logger.info(`Migration: lecture des user candidats...`) + + // l'utilisateur admin n'est pas repris + const candidats = await User.find({ role: "candidat" }) + logger.info(`Migration: ${candidats.length} user candidats à mettre à jour`) + const stats = { success: 0, failure: 0, alreadyExist: 0 } + + await asyncForEachGrouped(candidats, 100, async (candidat, index) => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { username, password, firstname, lastname, phone, email, role, type, last_action_date, is_anonymized, _id } = candidat + index % 1000 === 0 && logger.info(`import du candidat n°${index}`) + try { + if (type) { + await Appointment.updateMany({ applicant_id: candidat._id }, { $set: { applicant_user_type: parseEnumOrError(AppointmentUserType, type) } }) + } + const existingUser = await user2Repository.findOne({ email }) + if (existingUser) { + await Appointment.updateMany({ applicant_id: candidat._id }, { $set: { applicant_id: existingUser._id } }) + if (dayjs(candidat.last_action_date).isAfter(existingUser.last_connection)) { + await user2Repository.updateOne({ _id: existingUser._id }, { last_connection: candidat.last_action_date }) + } + stats.alreadyExist++ + return + } + const newUser: Omit = { + firstname, + lastname, + phone, + email, + last_connection: last_action_date, + is_anonymized: is_anonymized, + createdAt: last_action_date, + updatedAt: last_action_date, + origin: "migration user candidat", + history: [ + { + date: now, + reason: "migration", + status: is_anonymized ? UserEventType.DESACTIVE : UserEventType.ACTIF, + validation_type: VALIDATION_UTILISATEUR.AUTO, + }, + ], + } + await user2Repository.create({ ...newUser, _id: candidat._id }) + stats.success++ + } catch (err) { + logger.error(`erreur lors de l'import du user candidat avec id=${_id}`) + logger.error(err) + stats.failure++ + } + }) + logger.info(`Migration: user candidats terminés`) + const message = `${stats.success} user candidats repris avec succès. + ${stats.failure} user candidats en erreur. + ${stats.alreadyExist} users en doublon.` + logger.info(message) + await notifyToSlack({ + subject: "Migration multi-compte", + message, + error: stats.failure > 0, + }) + return stats +} + +const migrationRecruteurs = async () => { + logger.info(`Migration: lecture des user recruteurs...`) + const userRecruteurs: IUserRecruteur[] = await UserRecruteur.find({}) + logger.info(`Migration: ${userRecruteurs.length} user recruteurs à mettre à jour`) + const stats = { success: 0, failure: 0, entrepriseCreated: 0, cfaCreated: 0, userCreated: 0, adminAccess: 0, opcoAccess: 0 } + + await asyncForEachGrouped(userRecruteurs, 100, async (userRecruteur, index) => { + const { + last_name, + first_name, + opco, + idcc, + establishment_raison_sociale, + establishment_enseigne, + establishment_siret, + address_detail, + address, + geo_coordinates, + phone, + email, + scope, + type, + establishment_id, + origin: originRaw, + is_email_checked, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + is_qualiopi, + status, + last_connection, + createdAt, + updatedAt, + } = userRecruteur + const origin = originRaw || "user migration" + index % 1000 === 0 && logger.info(`import du user recruteur n°${index}`) + try { + const fieldsUpdate: Omit = { + firstname: first_name ?? "", + lastname: last_name ?? "", + phone: phone ?? "", + email, + last_connection: last_connection, + is_anonymized: false, + createdAt, + updatedAt, + origin, + history: [ + ...(is_email_checked + ? [ + { + date: createdAt, + reason: "migration", + status: UserEventType.VALIDATION_EMAIL, + validation_type: VALIDATION_UTILISATEUR.AUTO, + granted_by: "migration", + }, + ] + : []), + { + date: createdAt, + reason: "migration", + status: UserEventType.ACTIF, + validation_type: VALIDATION_UTILISATEUR.AUTO, + granted_by: "migration", + }, + ], + } + await user2Repository.create({ ...fieldsUpdate, _id: userRecruteur._id }) + stats.userCreated++ + if (type === ENTREPRISE) { + if (!establishment_siret) { + throw new Error("inattendu pour une ENTERPRISE: pas de establishment_siret") + } + if (!address_detail) { + throw new Error("inattendu pour une ENTERPRISE: pas de address_detail") + } + const entreprise: IEntreprise = { + establishment_siret, + address, + address_detail, + establishment_enseigne, + establishment_id, + establishment_raison_sociale, + geo_coordinates, + idcc, + opco, + origin, + createdAt, + updatedAt, + } + const createdEntreprise = await entrepriseRepository.create({ ...entreprise, _id: userRecruteur._id }) + stats.entrepriseCreated++ + const roleManagement: Omit = { + accessor_id: userRecruteur._id, + accessor_type: AccessEntityType.USER, + accessed_type: AccessEntityType.ENTREPRISE, + accessed_id: createdEntreprise._id, + createdAt: userRecruteur.createdAt, + updatedAt: userRecruteur.updatedAt, + origin, + history: userRecruteurStatusToRoleManagementStatus(status), + } + await roleManagementRepository.create(roleManagement) + } else if (type === "CFA") { + if (!establishment_siret) { + throw new Error("inattendu pour un CFA: pas de establishment_siret") + } + const cfa: Omit = { + establishment_siret, + address, + address_detail, + establishment_enseigne, + establishment_raison_sociale, + geo_coordinates, + origin, + createdAt, + updatedAt, + } + const createdCfa = await cfaRepository.create({ ...cfa, _id: userRecruteur._id }) + stats.cfaCreated++ + const roleManagement: Omit = { + accessor_id: userRecruteur._id, + accessor_type: AccessEntityType.USER, + accessed_type: AccessEntityType.CFA, + accessed_id: createdCfa._id, + createdAt: userRecruteur.createdAt, + updatedAt: userRecruteur.updatedAt, + origin, + history: userRecruteurStatusToRoleManagementStatus(status), + } + await roleManagementRepository.create(roleManagement) + } else if (type === "ADMIN") { + const roleManagement: Omit = { + accessor_id: userRecruteur._id, + accessor_type: AccessEntityType.USER, + accessed_type: AccessEntityType.ADMIN, + accessed_id: "", + createdAt: userRecruteur.createdAt, + updatedAt: userRecruteur.updatedAt, + origin, + history: userRecruteurStatusToRoleManagementStatus(status), + } + await roleManagementRepository.create(roleManagement) + stats.adminAccess++ + } else if (type === "OPCO") { + const opco = parseEnumOrError(OPCOS, scope ?? null) + const roleManagement: Omit = { + accessor_id: userRecruteur._id, + accessor_type: AccessEntityType.USER, + accessed_type: AccessEntityType.OPCO, + accessed_id: opco, + createdAt: userRecruteur.createdAt, + updatedAt: userRecruteur.updatedAt, + origin, + history: userRecruteurStatusToRoleManagementStatus(status), + } + await roleManagementRepository.create(roleManagement) + stats.opcoAccess++ + } + stats.success++ + } catch (err) { + logger.error(`erreur lors de l'import du user recruteur avec id=${userRecruteur._id}`) + logger.error(err) + stats.failure++ + } + }) + logger.info(`Migration: user candidats terminés`) + const message = `${stats.success} user recruteurs repris avec succès. + ${stats.failure} user recruteurs en erreur. + ${stats.userCreated} user créés. + ${stats.entrepriseCreated} entreprises créées. + ${stats.cfaCreated} CFA créés. + ` + logger.info(message) + await notifyToSlack({ + subject: "Migration multi-compte", + message, + error: stats.failure > 0, + }) + return stats +} + +function userRecruteurStatusToRoleManagementStatus(allStatus: IUserRecruteur["status"]): IRoleManagementEvent[] { + return allStatus.flatMap((statusEvent) => { + const { date, reason, status, user, validation_type } = statusEvent + const statusMapping: Record = { + [ETAT_UTILISATEUR.DESACTIVE]: AccessStatus.DENIED, + [ETAT_UTILISATEUR.VALIDE]: AccessStatus.GRANTED, + [ETAT_UTILISATEUR.ATTENTE]: AccessStatus.AWAITING_VALIDATION, + [ETAT_UTILISATEUR.ERROR]: null, + } + const accessStatus = status ? statusMapping[status] : null + if (accessStatus && date) { + const newEvent: IRoleManagementEvent = { + date, + reason: reason ?? "", + validation_type: validation_type, + granted_by: user, + status: accessStatus, + } + return [newEvent] + } else { + return [] + } + }) +} + +export const migrationUsers = async () => { + await user2Repository.deleteMany({}) + await entrepriseRepository.deleteMany({}) + await cfaRepository.deleteMany({}) + await roleManagementRepository.deleteMany({}) + const now = new Date() + await migrationRecruteurs() + await migrationCandidats(now) +} diff --git a/server/src/services/constant.service.ts b/server/src/services/constant.service.ts index 64b58ac07d..8842fa10f9 100644 --- a/server/src/services/constant.service.ts +++ b/server/src/services/constant.service.ts @@ -40,18 +40,18 @@ export const REGEX = { GEO: /^(-?\d+(\.\d+)?),\s*(-?\d+(\.\d+)?)$/, TELEPHONE: /^[0-9]{10}$/, } -export const OPCOS = { - AFDAS: "AFDAS", - AKTO: "AKTO / Opco entreprises et salariés des services à forte intensité de main d'oeuvre", - ATLAS: "ATLAS", - CONSTRUCTYS: "Constructys", - OPCOMMERCE: "L'Opcommerce", - OCAPIAT: "OCAPIAT", - OPCO2I: "OPCO 2i", - EP: "Opco entreprises de proximité", - MOBILITE: "Opco Mobilités", - SANTE: "Opco Santé", - UNIFORMATION: "Uniformation, l'Opco de la Cohésion sociale", +export enum OPCOS { + AFDAS = "AFDAS", + AKTO = "AKTO / Opco entreprises et salariés des services à forte intensité de main d'oeuvre", + ATLAS = "ATLAS", + CONSTRUCTYS = "Constructys", + OPCOMMERCE = "L'Opcommerce", + OCAPIAT = "OCAPIAT", + OPCO2I = "OPCO 2i", + EP = "Opco entreprises de proximité", + MOBILITE = "Opco Mobilités", + SANTE = "Opco Santé", + UNIFORMATION = "Uniformation, l'Opco de la Cohésion sociale", } export const NIVEAUX_POUR_LBA = { diff --git a/shared/constants/appointment.ts b/shared/constants/appointment.ts new file mode 100644 index 0000000000..088cd9b463 --- /dev/null +++ b/shared/constants/appointment.ts @@ -0,0 +1,4 @@ +export enum AppointmentUserType { + PARENT = "parent", + ETUDIENT = "etudiant", +} diff --git a/shared/models/appointments.model.ts b/shared/models/appointments.model.ts index d22af97484..ed527a76bb 100644 --- a/shared/models/appointments.model.ts +++ b/shared/models/appointments.model.ts @@ -1,8 +1,10 @@ import { Jsonify } from "type-fest" +import { AppointmentUserType } from "../constants/appointment" import { z } from "../helpers/zodWithOpenApi" import { zObjectId } from "./common" +import { enumToZod } from "./enumToZod" export const ZMailing = z .object({ @@ -34,6 +36,7 @@ export const ZAppointment = z created_at: z.date().default(() => new Date()), cfa_recipient_email: z.string(), is_anonymized: z.boolean().default(false), + applicant_user_type: enumToZod(AppointmentUserType).nullish(), }) .strict() .openapi("Appointment") diff --git a/shared/models/cfa.model.ts b/shared/models/cfa.model.ts index aba270a3bf..3436385dc2 100644 --- a/shared/models/cfa.model.ts +++ b/shared/models/cfa.model.ts @@ -1,15 +1,22 @@ +import { Jsonify } from "type-fest" + import { z } from "../helpers/zodWithOpenApi" +import { ZGlobalAddress } from "./address.model" + export const zCFA = z .object({ - establishment_state: z.string(), - is_qualiopi: z.string(), establishment_siret: z.string(), - establishment_raison_sociale: z.string(), - contacts: z.string(), - address_detail: z.string(), - address: z.string(), - geo_coordinates: z.string(), + establishment_raison_sociale: z.string().nullish(), + establishment_enseigne: z.string().nullish(), + address_detail: ZGlobalAddress.nullish(), + address: z.string().nullish(), + geo_coordinates: z.string().nullish(), + origin: z.string().nullish(), + createdAt: z.date(), + updatedAt: z.date(), }) .strict() - .openapi("Model") + +export type ICFA = z.output +export type ICFAJson = Jsonify> diff --git a/shared/models/entreprise.model.ts b/shared/models/entreprise.model.ts new file mode 100644 index 0000000000..37c7198f27 --- /dev/null +++ b/shared/models/entreprise.model.ts @@ -0,0 +1,25 @@ +import { Jsonify } from "type-fest" + +import { z } from "../helpers/zodWithOpenApi" + +import { ZGlobalAddress } from "./address.model" + +export const ZEntreprise = z + .object({ + establishment_siret: z.string(), + opco: z.string().nullish(), + idcc: z.string().nullish(), + establishment_raison_sociale: z.string().nullish(), + establishment_enseigne: z.string().nullish(), + address_detail: ZGlobalAddress.nullish(), + address: z.string().nullish(), + geo_coordinates: z.string().nullish(), + origin: z.string().nullish(), + establishment_id: z.string().nullish(), + createdAt: z.date(), + updatedAt: z.date(), + }) + .strict() + +export type IEntreprise = z.output +export type IEntrepriseJson = Jsonify> diff --git a/shared/models/enumToZod.ts b/shared/models/enumToZod.ts new file mode 100644 index 0000000000..f14f6ea382 --- /dev/null +++ b/shared/models/enumToZod.ts @@ -0,0 +1,6 @@ +import { z } from "../helpers/zodWithOpenApi" + +export function enumToZod(enumObject: Record) { + const enumValues = Object.values(enumObject) as string[] + return z.enum([enumValues[0], ...enumValues.slice(1)]) +} diff --git a/shared/models/roleManagement.model.ts b/shared/models/roleManagement.model.ts new file mode 100644 index 0000000000..e51e35e6bf --- /dev/null +++ b/shared/models/roleManagement.model.ts @@ -0,0 +1,48 @@ +import { z } from "../helpers/zodWithOpenApi" + +import { zObjectId } from "./common" +import { enumToZod } from "./enumToZod" +import { ZValidationUtilisateur } from "./user2.model.js" + +export enum AccessEntityType { + USER = "USER", + ENTREPRISE = "ENTREPRISE", + CFA = "CFA", + OPCO = "OPCO", + ADMIN = "ADMIN", +} + +export enum AccessStatus { + GRANTED = "GRANTED", + DENIED = "DENIED", + AWAITING_VALIDATION = "AWAITING_VALIDATION", +} + +export const ZRoleManagementEvent = z + .object({ + validation_type: ZValidationUtilisateur, + status: enumToZod(AccessStatus), + reason: z.string(), + date: z.date(), + granted_by: z.string().nullish(), + }) + .strict() + +export const ZAccessEntityType = enumToZod(AccessEntityType) + +export const ZRoleManagement = z + .object({ + accessor_id: zObjectId, + accessor_type: ZAccessEntityType, + accessed_id: z.string(), + accessed_type: ZAccessEntityType, + origin: z.string(), + history: z.array(ZRoleManagementEvent), + createdAt: z.date(), + updatedAt: z.date(), + }) + .strict() + +export type IRoleManagement = z.output + +export type IRoleManagementEvent = z.output diff --git a/shared/models/user2.model.ts b/shared/models/user2.model.ts new file mode 100644 index 0000000000..a66acca3cc --- /dev/null +++ b/shared/models/user2.model.ts @@ -0,0 +1,48 @@ +import { Jsonify } from "type-fest" + +import { VALIDATION_UTILISATEUR } from "../constants/recruteur" +import { extensions } from "../helpers/zodHelpers/zodPrimitives" +import { z } from "../helpers/zodWithOpenApi" + +import { zObjectId } from "./common" +import { enumToZod } from "./enumToZod" + +export enum UserEventType { + ACTIF = "ACTIF", + VALIDATION_EMAIL = "VALIDATION_EMAIL", + DESACTIVE = "DESACTIVE", +} + +export const ZValidationUtilisateur = enumToZod(VALIDATION_UTILISATEUR) + +export const ZUserStatusEvent = z + .object({ + validation_type: ZValidationUtilisateur, + status: enumToZod(UserEventType), + reason: z.string(), + granted_by: z.string().nullish(), + date: z.date(), + }) + .strict() + +export const ZUser2 = z + .object({ + id: zObjectId, + firstname: z.string(), + lastname: z.string(), + phone: extensions.phone(), + email: z.string().email(), + last_connection: z.date().nullish(), + is_anonymized: z.boolean(), + origin: z.string().nullish(), + history: z.array(ZUserStatusEvent), + createdAt: z.date(), + updatedAt: z.date(), + }) + .strict() + +export type IUser2 = z.output +export type IUser2Json = Jsonify> + +export type IUserStatusEvent = z.output +export type IUserStatusEventJson = Jsonify> From f10fe4d37a69e89331f19ed799fc8c0169a6d7f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9my=20Auricoste?= Date: Thu, 22 Feb 2024 09:55:33 +0100 Subject: [PATCH 02/78] fix: update talisman --- .talismanrc | 28 +++++++++------------------- 1 file changed, 9 insertions(+), 19 deletions(-) diff --git a/.talismanrc b/.talismanrc index 87f6a499d1..594f59b9e2 100644 --- a/.talismanrc +++ b/.talismanrc @@ -1,10 +1,6 @@ fileignoreconfig: - filename: .bin/scripts/get-vault-password-client.sh checksum: 721a0185a395874b58d38dd157aa326a15c73e7d381cfd968ad27fc5922ddfa3 -- filename: .bin/scripts/log-decrypt.sh - checksum: 7e0d7a04e6d4de4b87fbad65253ff513254290b6200a681d045f12aaa42bc5da -- filename: .bin/scripts/log-encrypt.sh - checksum: d0a8b1758472d11000b50fafdbc3b757068c5ac80b2e9d3cd534cc901cfe1a73 - filename: .bin/scripts/seed-apply.sh checksum: 49afe4f96fa13b38cf799d931085437d540b4c62eb05b2f15bc12cd3fb43268b - filename: .bin/scripts/seed-update.sh @@ -14,7 +10,7 @@ fileignoreconfig: - filename: .bin/scripts/setup-local-env.sh checksum: f3cd7443153a8d4bc742ea6075b2ea9a174572b736186c6759fbf29f59567fd8 - filename: .github/workflows/cypress.yml - checksum: 5ed183c8b57ecc5e12138b9eac54a0cd653e9d992b88ee72d404e51b0aaffe38 + checksum: 39f98fb68fdebf6a36959706adb43a8219a4b7781ac35329f957dc1cfa8b6de0 - filename: .github/workflows/deploy_preview.yml checksum: f54398af24ac144eafc27e69d18c78b1844e0a23b317bd79748ac6d3412ba0ef - filename: .github/workflows/release.yml @@ -26,17 +22,13 @@ fileignoreconfig: - filename: .infra/files/configs/mongodb/mongod.conf checksum: 718bee5f44edc101636be8f11173ede5b728f2858abc3c26466ff9435f0d11de - filename: .infra/files/configs/mongodb/seed.gpg - checksum: 0de7c38c7064044efaf4f02709788e4b1adea35c25a8a9b84d392c0888199649 + checksum: f3da269202d63aa1ad66b8eaa148076cf0135eec6b7fabe2394fcc3eabb466d2 - filename: .infra/files/scripts/seed.sh checksum: ddafc86248e8fd5f7c24ca5a62be703083f7704395f17fb7b43bc8e44227d561 - filename: .infra/local/mongod.conf checksum: bb2ce0c27102259a5fa39da1fb4460af9ad6ad58adc715312e53dcd69c8e6be7 - filename: .infra/vault/vault.yml checksum: d4f0802bd8a2ebe3af70e84b8fae5b9f9651b7dd9f6d529cdd9081520962195b -- filename: .yarn/patches/zod-npm-3.21.4-9f570b215c.patch - checksum: d429ff982063e6476ea8d8d91ac1173a1279d1c9bf0488b6e05396affeb8c6cf -- filename: cypress/utils/generateRandomString.ts - checksum: 09a43c5a808533a634b1890a8173d9ef173ed0402337dadb5bcd19c013f02d3c - filename: docker-compose.yml checksum: 8cdd1da6c1155f26b417a27e26311d4f00b7d8bd6c21f1f86c1c7cb3f0599e6a - filename: server/.env.test @@ -60,11 +52,11 @@ fileignoreconfig: - filename: server/src/jobs/lba_recruteur/formulaire/misc/removeVersionKeyFromRecruiters.ts checksum: 3cd111d8c109cfec357bae48af70d0cf5644d02cd2c4b9afc5b8aa07bccbd535 - filename: server/src/security/accessTokenService.ts - checksum: 34d8d8580698ec15cc8f44a7dc2576dca96ac13bfe2176408a87c7a675d27fba + checksum: 2183e326a88ae3c10193a7033ab5fd421ce576cd1e234c323df247da335f74d7 - filename: server/src/services/application.service.ts checksum: 935cd8f213565ba7bcc2925fca149aaa6cbe9bb5e393a13ab3525dff6ad17234 - filename: server/src/services/userRecruteur.service.ts - checksum: f5a838798099a828d28c8dfb62682d5753040a921e27bf6d7f46281091678240 + checksum: fb8fc73f5ac4491b2a7be99ceaebf96c79bffa63fa6b164eed6e2d3d9fe5dae0 - filename: server/static/templates/mail-bienvenue.mjml.ejs checksum: 40c4c93702c7727cd8d76f5eb340b6566c31ef36698866261316be9ed0684150 - filename: server/tests/integration/http/formationV1.test.ts @@ -80,7 +72,7 @@ fileignoreconfig: - filename: server/tests/unit/services/eligibleTrainingsForAppointment.service.test.ts checksum: 089218ccdaf2553d5a427cb81d0b556f51badaba5b5523299e5fe74e7ef5a802 - filename: server/tests/unit/util.test.ts - checksum: 08b0cec8cda8f451fd386813ca4f4a23e0076326a5cffebedf53809940d08f1b + checksum: d38ae060280f0cb1579894ac2fcbfdd0a48b49eafc54f7ba3c7db0e422230547 - filename: server/tests/unit/utils/isValidEmail.test.ts checksum: dd0ea5aa2cf75c61cc8f179723bec24e2c1a1599b838bd4940244d913dfed2d1 - filename: server/tests/unit/validateSendApplication.test.ts @@ -88,9 +80,9 @@ fileignoreconfig: - filename: shared/constants/recruteur.ts checksum: 28af032d2eb26aec7dd3bb1d32253f992a036626c36a92eb1e7ff07599fd0b2b - filename: shared/helpers/generateUri.ts - checksum: 8b67b7631c668e1953d04d9918a2f78587164b8d3a8feecdabcbd3ff76c19d47 + checksum: 6542db0d3eca959c6e81d574f8b71d4b18d2f1af21042ca5ed4dff319cd39555 - filename: shared/helpers/openapi/__snapshots__/generateOpenapi.test.ts.snap - checksum: 44f061f338df2c44728104a4318ffebef0d8c3d9966b0e383cfa322d09b0c8f8 + checksum: 4c52ccaab38f3151e125d924d06eafd9f861be93c1d39cf70b563d081a3851fc - filename: shared/helpers/openapi/generateOpenapi.test.ts checksum: d7b85c3dff488cec523d78f0926e15dbea41071a1864bda62b4d6caeb2541df3 - filename: shared/models/applications.model.ts @@ -98,7 +90,7 @@ fileignoreconfig: - filename: shared/models/lbaItem.model.ts checksum: 144ab34674299cdac89d96ffa6ed834814135c54e1621e1fa47ec5012924f862 - filename: shared/routes/appointments.routes.ts - checksum: df667fad7e0c8016acc4c2574d5b450881046ec5c6c08284d6a45fcce3507d30 + checksum: 46d94affa911e46d6e3f72d453412c4b5378a4ef71e6ee6cb3ab2f43eee3d5d4 - filename: shared/routes/formulaire.route.ts checksum: aaebcb3889eeb066dd5b44f95e8d23a1a988608b382eb107dad4d87d24a97074 - filename: shared/routes/password.routes.ts @@ -111,14 +103,12 @@ fileignoreconfig: checksum: 7cce935653407e000b35e98bd365a003e538aed4fed432a9a404d4f2412dd2df - filename: ui/components/ItemDetail/ItemDetail.tsx checksum: 1fcc0442306f83b5e45bf7da67304527598d7749b9e2642c6d4628d3b4f15a9c -- filename: ui/components/RDV/DemandeDeContact.tsx - checksum: 9f7a5f41a61396cd0ca432a722f34ea4a00ccefcc265ad9158cc0cb76d6f99c7 - filename: ui/components/Ressources/conseilsEtAstuces.tsx checksum: b9f16607c1fb319c2940223e7e9ce5acc48b8b5a5b519e2bb79210e3f0feaf17 - filename: ui/components/Ressources/misesEnSituation.tsx checksum: 40d3b51d46662db192ae3b8a5ecd5090445008dd7842425f34f7f4058482f3d0 - filename: ui/components/SearchForTrainingsAndJobs/services/handleSessionStorage.ts - checksum: 524d0a08451f00ff0dae0cb2d6c5c2b0045e410ee7504f74b5a53f8f4c7b5085 + checksum: c3c1aceede1040a9001bdc27cebbec7b3aeb0bb251b8b4264bb9d33da074af60 - filename: ui/components/espace_pro/Admin/utilisateurs/UserList.tsx checksum: a50177afa593bae5707bdba29ef27b8f2ed0bc58487491bfff580e7e1f422243 - filename: ui/components/espace_pro/Admin/utilisateurs/infoDetails/InfoDetails.tsx From 7fe6d7f5c486517aaaa6af614a71045ec68e1efb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9my=20Auricoste?= Date: Thu, 22 Feb 2024 10:06:55 +0100 Subject: [PATCH 03/78] fix: typing + small refactor migration script --- .../multiCompte/roleManagement.schema.ts | 2 +- .../model/schema/multiCompte/user2.schema.ts | 2 +- server/src/common/utils/asyncUtils.ts | 2 +- server/src/jobs/multiCompte/migrationUsers.ts | 149 +++++++++--------- 4 files changed, 78 insertions(+), 77 deletions(-) diff --git a/server/src/common/model/schema/multiCompte/roleManagement.schema.ts b/server/src/common/model/schema/multiCompte/roleManagement.schema.ts index 40f19439b4..953b11aaee 100644 --- a/server/src/common/model/schema/multiCompte/roleManagement.schema.ts +++ b/server/src/common/model/schema/multiCompte/roleManagement.schema.ts @@ -1,6 +1,6 @@ +import { VALIDATION_UTILISATEUR } from "shared/constants/recruteur.js" import { AccessEntityType, AccessStatus, IRoleManagement, IRoleManagementEvent } from "shared/models/roleManagement.model.js" -import { VALIDATION_UTILISATEUR } from "../../../../services/constant.service.js" import { Schema } from "../../../mongodb.js" import { buildMongooseModel } from "./buildMongooseModel.js" diff --git a/server/src/common/model/schema/multiCompte/user2.schema.ts b/server/src/common/model/schema/multiCompte/user2.schema.ts index c89ca85a03..1f769ffde4 100644 --- a/server/src/common/model/schema/multiCompte/user2.schema.ts +++ b/server/src/common/model/schema/multiCompte/user2.schema.ts @@ -1,6 +1,6 @@ +import { VALIDATION_UTILISATEUR } from "shared/constants/recruteur.js" import { IUser2, IUserStatusEvent, UserEventType } from "shared/models/user2.model.js" -import { VALIDATION_UTILISATEUR } from "../../../../services/constant.service.js" import { Schema } from "../../../mongodb.js" import { buildMongooseModel } from "./buildMongooseModel.js" diff --git a/server/src/common/utils/asyncUtils.ts b/server/src/common/utils/asyncUtils.ts index d6acea50b9..157d076578 100644 --- a/server/src/common/utils/asyncUtils.ts +++ b/server/src/common/utils/asyncUtils.ts @@ -11,7 +11,7 @@ export const asyncForEachGrouped = async (array: T[], groupSize: number, call } } -export const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) +export const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) export function timeout(promise, millis) { let timeout: NodeJS.Timeout | null = null diff --git a/server/src/jobs/multiCompte/migrationUsers.ts b/server/src/jobs/multiCompte/migrationUsers.ts index f09b8413c0..6f88d44827 100644 --- a/server/src/jobs/multiCompte/migrationUsers.ts +++ b/server/src/jobs/multiCompte/migrationUsers.ts @@ -1,5 +1,7 @@ import dayjs from "dayjs" import { AppointmentUserType } from "shared/constants/appointment.js" +import { EApplicantRole } from "shared/constants/rdva.js" +import { ENTREPRISE, ETAT_UTILISATEUR, OPCOS, VALIDATION_UTILISATEUR } from "shared/constants/recruteur.js" import { ICFA } from "shared/models/cfa.model.js" import { IEntreprise } from "shared/models/entreprise.model.js" import { AccessEntityType, AccessStatus, IRoleManagement, IRoleManagementEvent } from "shared/models/roleManagement.model.js" @@ -15,71 +17,15 @@ import { user2Repository } from "../../common/model/schema/multiCompte/user2.sch import { asyncForEachGrouped } from "../../common/utils/asyncUtils.js" import { parseEnumOrError } from "../../common/utils/enumUtils.js" import { notifyToSlack } from "../../common/utils/slackUtils.js" -import { ENTREPRISE, ETAT_UTILISATEUR, OPCOS, VALIDATION_UTILISATEUR } from "../../services/constant.service.js" -const migrationCandidats = async (now: Date) => { - logger.info(`Migration: lecture des user candidats...`) - - // l'utilisateur admin n'est pas repris - const candidats = await User.find({ role: "candidat" }) - logger.info(`Migration: ${candidats.length} user candidats à mettre à jour`) - const stats = { success: 0, failure: 0, alreadyExist: 0 } - - await asyncForEachGrouped(candidats, 100, async (candidat, index) => { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { username, password, firstname, lastname, phone, email, role, type, last_action_date, is_anonymized, _id } = candidat - index % 1000 === 0 && logger.info(`import du candidat n°${index}`) - try { - if (type) { - await Appointment.updateMany({ applicant_id: candidat._id }, { $set: { applicant_user_type: parseEnumOrError(AppointmentUserType, type) } }) - } - const existingUser = await user2Repository.findOne({ email }) - if (existingUser) { - await Appointment.updateMany({ applicant_id: candidat._id }, { $set: { applicant_id: existingUser._id } }) - if (dayjs(candidat.last_action_date).isAfter(existingUser.last_connection)) { - await user2Repository.updateOne({ _id: existingUser._id }, { last_connection: candidat.last_action_date }) - } - stats.alreadyExist++ - return - } - const newUser: Omit = { - firstname, - lastname, - phone, - email, - last_connection: last_action_date, - is_anonymized: is_anonymized, - createdAt: last_action_date, - updatedAt: last_action_date, - origin: "migration user candidat", - history: [ - { - date: now, - reason: "migration", - status: is_anonymized ? UserEventType.DESACTIVE : UserEventType.ACTIF, - validation_type: VALIDATION_UTILISATEUR.AUTO, - }, - ], - } - await user2Repository.create({ ...newUser, _id: candidat._id }) - stats.success++ - } catch (err) { - logger.error(`erreur lors de l'import du user candidat avec id=${_id}`) - logger.error(err) - stats.failure++ - } - }) - logger.info(`Migration: user candidats terminés`) - const message = `${stats.success} user candidats repris avec succès. - ${stats.failure} user candidats en erreur. - ${stats.alreadyExist} users en doublon.` - logger.info(message) - await notifyToSlack({ - subject: "Migration multi-compte", - message, - error: stats.failure > 0, - }) - return stats +export const migrationUsers = async () => { + await user2Repository.deleteMany({}) + await entrepriseRepository.deleteMany({}) + await cfaRepository.deleteMany({}) + await roleManagementRepository.deleteMany({}) + const now = new Date() + await migrationRecruteurs() + await migrationCandidats(now) } const migrationRecruteurs = async () => { @@ -263,6 +209,71 @@ const migrationRecruteurs = async () => { return stats } +const migrationCandidats = async (now: Date) => { + logger.info(`Migration: lecture des user candidats...`) + + // l'utilisateur admin n'est pas repris + const candidats = await User.find({ role: EApplicantRole.CANDIDAT }) + logger.info(`Migration: ${candidats.length} user candidats à mettre à jour`) + const stats = { success: 0, failure: 0, alreadyExist: 0 } + + await asyncForEachGrouped(candidats, 100, async (candidat, index) => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { firstname, lastname, phone, email, role, type, last_action_date, is_anonymized, _id } = candidat + index % 1000 === 0 && logger.info(`import du candidat n°${index}`) + try { + if (type) { + await Appointment.updateMany({ applicant_id: candidat._id }, { $set: { applicant_user_type: parseEnumOrError(AppointmentUserType, type) } }) + } + const existingUser = await user2Repository.findOne({ email }) + if (existingUser) { + await Appointment.updateMany({ applicant_id: candidat._id }, { $set: { applicant_id: existingUser._id } }) + if (dayjs(candidat.last_action_date).isAfter(existingUser.last_connection)) { + await user2Repository.updateOne({ _id: existingUser._id }, { last_connection: candidat.last_action_date }) + } + stats.alreadyExist++ + return + } + const newUser: Omit = { + firstname, + lastname, + phone, + email, + last_connection: last_action_date, + is_anonymized: is_anonymized, + createdAt: last_action_date, + updatedAt: last_action_date, + origin: "migration user candidat", + history: [ + { + date: now, + reason: "migration", + status: is_anonymized ? UserEventType.DESACTIVE : UserEventType.ACTIF, + validation_type: VALIDATION_UTILISATEUR.AUTO, + }, + ], + } + await user2Repository.create({ ...newUser, _id: candidat._id }) + stats.success++ + } catch (err) { + logger.error(`erreur lors de l'import du user candidat avec id=${_id}`) + logger.error(err) + stats.failure++ + } + }) + logger.info(`Migration: user candidats terminés`) + const message = `${stats.success} user candidats repris avec succès. + ${stats.failure} user candidats en erreur. + ${stats.alreadyExist} users en doublon.` + logger.info(message) + await notifyToSlack({ + subject: "Migration multi-compte", + message, + error: stats.failure > 0, + }) + return stats +} + function userRecruteurStatusToRoleManagementStatus(allStatus: IUserRecruteur["status"]): IRoleManagementEvent[] { return allStatus.flatMap((statusEvent) => { const { date, reason, status, user, validation_type } = statusEvent @@ -287,13 +298,3 @@ function userRecruteurStatusToRoleManagementStatus(allStatus: IUserRecruteur["st } }) } - -export const migrationUsers = async () => { - await user2Repository.deleteMany({}) - await entrepriseRepository.deleteMany({}) - await cfaRepository.deleteMany({}) - await roleManagementRepository.deleteMany({}) - const now = new Date() - await migrationRecruteurs() - await migrationCandidats(now) -} From ca842ed6e2c7d42ceef55d302e77c11aadaab6f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9my=20Auricoste?= Date: Thu, 22 Feb 2024 10:57:04 +0100 Subject: [PATCH 04/78] fix: renommage des champs + refactor --- .../schema/appointments/appointment.schema.ts | 2 +- .../model/schema/multiCompte/user2.schema.ts | 31 ++--- .../appointmentRequest.controller.ts | 2 +- server/src/jobs/multiCompte/migrationUsers.ts | 120 +++++++++--------- shared/models/appointments.model.ts | 4 +- shared/models/cfa.model.ts | 2 + shared/models/entreprise.model.ts | 19 +-- shared/models/roleManagement.model.ts | 11 +- shared/models/user2.model.ts | 11 +- 9 files changed, 98 insertions(+), 104 deletions(-) diff --git a/server/src/common/model/schema/appointments/appointment.schema.ts b/server/src/common/model/schema/appointments/appointment.schema.ts index 9140f15028..bd12fed578 100644 --- a/server/src/common/model/schema/appointments/appointment.schema.ts +++ b/server/src/common/model/schema/appointments/appointment.schema.ts @@ -148,7 +148,7 @@ export const appointmentSchema = new Schema( default: null, description: "Adresse email CFA", }, - applicant_user_type: { + applicant_type: { type: String, enum: [...Object.values(AppointmentUserType), null], default: null, diff --git a/server/src/common/model/schema/multiCompte/user2.schema.ts b/server/src/common/model/schema/multiCompte/user2.schema.ts index 1f769ffde4..4c85613318 100644 --- a/server/src/common/model/schema/multiCompte/user2.schema.ts +++ b/server/src/common/model/schema/multiCompte/user2.schema.ts @@ -37,6 +37,14 @@ const userStatusEventSchema = new Schema( const User2Schema = new Schema( { + origin: { + type: String, + description: "Origine de la creation de l'utilisateur (ex: Campagne mail, lien web, etc...) pour suivi", + }, + status: { + type: [userStatusEventSchema], + description: "Evénements liés au cycle de vie de l'utilisateur", + }, firstname: { type: String, default: null, @@ -47,34 +55,21 @@ const User2Schema = new Schema( default: null, description: "Le nom", }, - phone: { + email: { type: String, default: null, - description: "Le numéro de téléphone", + description: "L'email", }, - email: { + phone: { type: String, default: null, - description: "L'email", + description: "Le numéro de téléphone", }, - last_connection: { + last_action_date: { type: Date, default: null, description: "Date de dernière connexion", }, - is_anonymized: { - type: Boolean, - default: false, - description: "Si l'enregistrement est anonymisé", - }, - history: { - type: [userStatusEventSchema], - description: "Evénements liés au cycle de vie de l'utilisateur", - }, - origin: { - type: String, - description: "Origine de la creation de l'utilisateur (ex: Campagne mail, lien web, etc...) pour suivi", - }, }, { timestamps: true, diff --git a/server/src/http/controllers/appointmentRequest.controller.ts b/server/src/http/controllers/appointmentRequest.controller.ts index 6d11876e70..768d628ea8 100644 --- a/server/src/http/controllers/appointmentRequest.controller.ts +++ b/server/src/http/controllers/appointmentRequest.controller.ts @@ -329,7 +329,7 @@ export default (server: Server) => { eligibleTrainingsForAppointmentService.getParameterByCleMinistereEducatif({ cleMinistereEducatif: cle_ministere_educatif, }), - users.getUserById(appointment.applicant_id), + users.getUserById(appointment.applicant_id.toString()), ]) if (!user || !eligibleTrainingsForAppointment) throw Boom.notFound() diff --git a/server/src/jobs/multiCompte/migrationUsers.ts b/server/src/jobs/multiCompte/migrationUsers.ts index 6f88d44827..34b2ba1f21 100644 --- a/server/src/jobs/multiCompte/migrationUsers.ts +++ b/server/src/jobs/multiCompte/migrationUsers.ts @@ -5,7 +5,7 @@ import { ENTREPRISE, ETAT_UTILISATEUR, OPCOS, VALIDATION_UTILISATEUR } from "sha import { ICFA } from "shared/models/cfa.model.js" import { IEntreprise } from "shared/models/entreprise.model.js" import { AccessEntityType, AccessStatus, IRoleManagement, IRoleManagementEvent } from "shared/models/roleManagement.model.js" -import { UserEventType, IUser2 } from "shared/models/user2.model.js" +import { UserEventType, IUser2, IUserStatusEvent } from "shared/models/user2.model.js" import { IUserRecruteur } from "shared/models/usersRecruteur.model.js" import { logger } from "../../common/logger.js" @@ -55,7 +55,7 @@ const migrationRecruteurs = async () => { is_email_checked, // eslint-disable-next-line @typescript-eslint/no-unused-vars is_qualiopi, - status, + status: oldStatus, last_connection, createdAt, updatedAt, @@ -63,38 +63,36 @@ const migrationRecruteurs = async () => { const origin = originRaw || "user migration" index % 1000 === 0 && logger.info(`import du user recruteur n°${index}`) try { - const fieldsUpdate: Omit = { + const newStatus: IUserStatusEvent[] = [] + if (is_email_checked) { + newStatus.push({ + date: createdAt, + reason: "migration", + status: UserEventType.VALIDATION_EMAIL, + validation_type: VALIDATION_UTILISATEUR.AUTO, + granted_by: "migration", + }) + } + newStatus.push({ + date: createdAt, + reason: "migration", + status: UserEventType.ACTIF, + validation_type: VALIDATION_UTILISATEUR.AUTO, + granted_by: "migration", + }) + const newUser: IUser2 = { + _id: userRecruteur._id, firstname: first_name ?? "", lastname: last_name ?? "", phone: phone ?? "", email, - last_connection: last_connection, - is_anonymized: false, + last_action_date: last_connection, createdAt, updatedAt, origin, - history: [ - ...(is_email_checked - ? [ - { - date: createdAt, - reason: "migration", - status: UserEventType.VALIDATION_EMAIL, - validation_type: VALIDATION_UTILISATEUR.AUTO, - granted_by: "migration", - }, - ] - : []), - { - date: createdAt, - reason: "migration", - status: UserEventType.ACTIF, - validation_type: VALIDATION_UTILISATEUR.AUTO, - granted_by: "migration", - }, - ], + status: newStatus, } - await user2Repository.create({ ...fieldsUpdate, _id: userRecruteur._id }) + await user2Repository.create(newUser) stats.userCreated++ if (type === ENTREPRISE) { if (!establishment_siret) { @@ -104,37 +102,38 @@ const migrationRecruteurs = async () => { throw new Error("inattendu pour une ENTERPRISE: pas de address_detail") } const entreprise: IEntreprise = { - establishment_siret, + _id: userRecruteur._id, + origin, + siret: establishment_siret, address, address_detail, - establishment_enseigne, + enseigne: establishment_enseigne, establishment_id, - establishment_raison_sociale, + raison_sociale: establishment_raison_sociale, geo_coordinates, idcc, opco, - origin, createdAt, updatedAt, } - const createdEntreprise = await entrepriseRepository.create({ ...entreprise, _id: userRecruteur._id }) + const createdEntreprise = await entrepriseRepository.create(entreprise) stats.entrepriseCreated++ - const roleManagement: Omit = { - accessor_id: userRecruteur._id, - accessor_type: AccessEntityType.USER, - accessed_type: AccessEntityType.ENTREPRISE, - accessed_id: createdEntreprise._id, + const roleManagement: Omit = { + user_id: userRecruteur._id, + authorized_type: AccessEntityType.ENTREPRISE, + authorized_id: createdEntreprise._id, createdAt: userRecruteur.createdAt, updatedAt: userRecruteur.updatedAt, origin, - history: userRecruteurStatusToRoleManagementStatus(status), + status: userRecruteurStatusToRoleManagementStatus(oldStatus), } await roleManagementRepository.create(roleManagement) } else if (type === "CFA") { if (!establishment_siret) { throw new Error("inattendu pour un CFA: pas de establishment_siret") } - const cfa: Omit = { + const cfa: ICFA = { + _id: userRecruteur._id, establishment_siret, address, address_detail, @@ -145,43 +144,40 @@ const migrationRecruteurs = async () => { createdAt, updatedAt, } - const createdCfa = await cfaRepository.create({ ...cfa, _id: userRecruteur._id }) + const createdCfa = await cfaRepository.create(cfa) stats.cfaCreated++ const roleManagement: Omit = { - accessor_id: userRecruteur._id, - accessor_type: AccessEntityType.USER, - accessed_type: AccessEntityType.CFA, - accessed_id: createdCfa._id, + user_id: userRecruteur._id, + authorized_type: AccessEntityType.CFA, + authorized_id: createdCfa._id, createdAt: userRecruteur.createdAt, updatedAt: userRecruteur.updatedAt, origin, - history: userRecruteurStatusToRoleManagementStatus(status), + status: userRecruteurStatusToRoleManagementStatus(oldStatus), } await roleManagementRepository.create(roleManagement) } else if (type === "ADMIN") { const roleManagement: Omit = { - accessor_id: userRecruteur._id, - accessor_type: AccessEntityType.USER, - accessed_type: AccessEntityType.ADMIN, - accessed_id: "", + user_id: userRecruteur._id, + authorized_type: AccessEntityType.ADMIN, + authorized_id: "", createdAt: userRecruteur.createdAt, updatedAt: userRecruteur.updatedAt, origin, - history: userRecruteurStatusToRoleManagementStatus(status), + status: userRecruteurStatusToRoleManagementStatus(oldStatus), } await roleManagementRepository.create(roleManagement) stats.adminAccess++ } else if (type === "OPCO") { const opco = parseEnumOrError(OPCOS, scope ?? null) const roleManagement: Omit = { - accessor_id: userRecruteur._id, - accessor_type: AccessEntityType.USER, - accessed_type: AccessEntityType.OPCO, - accessed_id: opco, + user_id: userRecruteur._id, + authorized_type: AccessEntityType.OPCO, + authorized_id: opco, createdAt: userRecruteur.createdAt, updatedAt: userRecruteur.updatedAt, origin, - history: userRecruteurStatusToRoleManagementStatus(status), + status: userRecruteurStatusToRoleManagementStatus(oldStatus), } await roleManagementRepository.create(roleManagement) stats.opcoAccess++ @@ -223,28 +219,28 @@ const migrationCandidats = async (now: Date) => { index % 1000 === 0 && logger.info(`import du candidat n°${index}`) try { if (type) { - await Appointment.updateMany({ applicant_id: candidat._id }, { $set: { applicant_user_type: parseEnumOrError(AppointmentUserType, type) } }) + await Appointment.updateMany({ applicant_id: candidat._id }, { $set: { applicant_type: parseEnumOrError(AppointmentUserType, type) } }) } const existingUser = await user2Repository.findOne({ email }) if (existingUser) { await Appointment.updateMany({ applicant_id: candidat._id }, { $set: { applicant_id: existingUser._id } }) - if (dayjs(candidat.last_action_date).isAfter(existingUser.last_connection)) { - await user2Repository.updateOne({ _id: existingUser._id }, { last_connection: candidat.last_action_date }) + if (dayjs(candidat.last_action_date).isAfter(existingUser.last_action_date)) { + await user2Repository.updateOne({ _id: existingUser._id }, { last_action_date: candidat.last_action_date }) } stats.alreadyExist++ return } - const newUser: Omit = { + const newUser: IUser2 = { + _id: candidat._id, firstname, lastname, phone, email, - last_connection: last_action_date, - is_anonymized: is_anonymized, + last_action_date: last_action_date, createdAt: last_action_date, updatedAt: last_action_date, origin: "migration user candidat", - history: [ + status: [ { date: now, reason: "migration", @@ -253,7 +249,7 @@ const migrationCandidats = async (now: Date) => { }, ], } - await user2Repository.create({ ...newUser, _id: candidat._id }) + await user2Repository.create(newUser) stats.success++ } catch (err) { logger.error(`erreur lors de l'import du user candidat avec id=${_id}`) diff --git a/shared/models/appointments.model.ts b/shared/models/appointments.model.ts index fa1eb68188..cd8e066404 100644 --- a/shared/models/appointments.model.ts +++ b/shared/models/appointments.model.ts @@ -34,7 +34,7 @@ export const ZMailing = z export const ZAppointment = z .object({ _id: zObjectId, - applicant_id: z.string(), + applicant_id: zObjectId, cfa_intention_to_applicant: z.string().nullish(), cfa_message_to_applicant_date: z.date().nullish(), cfa_message_to_applicant: z.string().nullish(), @@ -49,7 +49,7 @@ export const ZAppointment = z cle_ministere_educatif: z.string().nullish(), created_at: z.date().default(() => new Date()), cfa_recipient_email: z.string(), - applicant_user_type: enumToZod(AppointmentUserType).nullish(), + applicant_type: enumToZod(AppointmentUserType).nullish(), }) .strict() .openapi("Appointment") diff --git a/shared/models/cfa.model.ts b/shared/models/cfa.model.ts index 3436385dc2..cb5c29fe27 100644 --- a/shared/models/cfa.model.ts +++ b/shared/models/cfa.model.ts @@ -3,9 +3,11 @@ import { Jsonify } from "type-fest" import { z } from "../helpers/zodWithOpenApi" import { ZGlobalAddress } from "./address.model" +import { zObjectId } from "./common" export const zCFA = z .object({ + _id: zObjectId, establishment_siret: z.string(), establishment_raison_sociale: z.string().nullish(), establishment_enseigne: z.string().nullish(), diff --git a/shared/models/entreprise.model.ts b/shared/models/entreprise.model.ts index 37c7198f27..c13e3573bc 100644 --- a/shared/models/entreprise.model.ts +++ b/shared/models/entreprise.model.ts @@ -3,21 +3,24 @@ import { Jsonify } from "type-fest" import { z } from "../helpers/zodWithOpenApi" import { ZGlobalAddress } from "./address.model" +import { zObjectId } from "./common" export const ZEntreprise = z .object({ - establishment_siret: z.string(), - opco: z.string().nullish(), + _id: zObjectId, + origin: z.string().nullish(), + createdAt: z.date(), + updatedAt: z.date(), + siret: z.string(), + raison_sociale: z.string().nullish(), + enseigne: z.string().nullish(), idcc: z.string().nullish(), - establishment_raison_sociale: z.string().nullish(), - establishment_enseigne: z.string().nullish(), - address_detail: ZGlobalAddress.nullish(), address: z.string().nullish(), + address_detail: ZGlobalAddress.nullish(), geo_coordinates: z.string().nullish(), - origin: z.string().nullish(), + opco: z.string().nullish(), + establishment_id: z.string().nullish(), - createdAt: z.date(), - updatedAt: z.date(), }) .strict() diff --git a/shared/models/roleManagement.model.ts b/shared/models/roleManagement.model.ts index e51e35e6bf..370948d774 100644 --- a/shared/models/roleManagement.model.ts +++ b/shared/models/roleManagement.model.ts @@ -32,17 +32,16 @@ export const ZAccessEntityType = enumToZod(AccessEntityType) export const ZRoleManagement = z .object({ - accessor_id: zObjectId, - accessor_type: ZAccessEntityType, - accessed_id: z.string(), - accessed_type: ZAccessEntityType, + _id: zObjectId, origin: z.string(), - history: z.array(ZRoleManagementEvent), + status: z.array(ZRoleManagementEvent), + authorized_id: z.string(), + authorized_type: ZAccessEntityType, + user_id: zObjectId, createdAt: z.date(), updatedAt: z.date(), }) .strict() export type IRoleManagement = z.output - export type IRoleManagementEvent = z.output diff --git a/shared/models/user2.model.ts b/shared/models/user2.model.ts index a66acca3cc..d6c6e9d307 100644 --- a/shared/models/user2.model.ts +++ b/shared/models/user2.model.ts @@ -27,15 +27,14 @@ export const ZUserStatusEvent = z export const ZUser2 = z .object({ - id: zObjectId, + _id: zObjectId, + origin: z.string().nullish(), + status: z.array(ZUserStatusEvent), firstname: z.string(), lastname: z.string(), - phone: extensions.phone(), email: z.string().email(), - last_connection: z.date().nullish(), - is_anonymized: z.boolean(), - origin: z.string().nullish(), - history: z.array(ZUserStatusEvent), + phone: extensions.phone(), + last_action_date: z.date().nullish(), createdAt: z.date(), updatedAt: z.date(), }) From 87427afaefe76879a9e91c46f0a6d20d03c0c09d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9my=20Auricoste?= Date: Thu, 22 Feb 2024 17:55:46 +0100 Subject: [PATCH 05/78] fix: renommage des champs + commande + test ok --- server/src/commands.ts | 6 ++++ .../model/schema/multiCompte/cfa.schema.ts | 14 ++++---- .../schema/multiCompte/entreprise.schema.ts | 36 +++++++++---------- .../multiCompte/roleManagement.schema.ts | 25 ++++++------- server/src/jobs/jobs.ts | 4 +++ server/src/jobs/multiCompte/migrationUsers.ts | 9 ++--- shared/models/cfa.model.ts | 12 +++---- shared/models/entreprise.model.ts | 20 +++++------ shared/models/roleManagement.model.ts | 20 +++++------ 9 files changed, 74 insertions(+), 72 deletions(-) diff --git a/server/src/commands.ts b/server/src/commands.ts index 32bb82bdfb..b28b1806ca 100644 --- a/server/src/commands.ts +++ b/server/src/commands.ts @@ -607,6 +607,12 @@ program .requiredOption("--from-date , [fromDate]", "format DD-MM-YYYY. Date depuis laquelle les prises de rendez-vous sont renvoyéees") .action(createJobAction("prdv:emails:resend")) +program + .command("migrate-multi-compte") + .description("Migre les données vers les tables multi-compte") + .option("-q, --queued", "Run job asynchronously", false) + .action(createJobAction("migrate-multi-compte")) + export async function startCLI() { await program.parseAsync(process.argv) } diff --git a/server/src/common/model/schema/multiCompte/cfa.schema.ts b/server/src/common/model/schema/multiCompte/cfa.schema.ts index 27193e6015..5764668edd 100644 --- a/server/src/common/model/schema/multiCompte/cfa.schema.ts +++ b/server/src/common/model/schema/multiCompte/cfa.schema.ts @@ -6,15 +6,19 @@ import { buildMongooseModel } from "./buildMongooseModel.js" const cfaSchema = new Schema( { - establishment_siret: { + origin: { + type: String, + description: "Origine de la creation (ex: Campagne mail, lien web, etc...) pour suivi", + }, + siret: { type: String, description: "Siret de l'établissement", }, - establishment_raison_sociale: { + raison_sociale: { type: String, description: "Raison social de l'établissement", }, - establishment_enseigne: { + enseigne: { type: String, default: null, description: "Enseigne de l'établissement", @@ -32,10 +36,6 @@ const cfaSchema = new Schema( default: null, description: "Latitude/Longitude de l'adresse de l'entreprise", }, - origin: { - type: String, - description: "Origine de la creation (ex: Campagne mail, lien web, etc...) pour suivi", - }, }, { timestamps: true, diff --git a/server/src/common/model/schema/multiCompte/entreprise.schema.ts b/server/src/common/model/schema/multiCompte/entreprise.schema.ts index dc3450402e..a0fa4c7374 100644 --- a/server/src/common/model/schema/multiCompte/entreprise.schema.ts +++ b/server/src/common/model/schema/multiCompte/entreprise.schema.ts @@ -6,48 +6,48 @@ import { buildMongooseModel } from "./buildMongooseModel.js" const entrepriseSchema = new Schema( { - establishment_siret: { + origin: { type: String, - description: "Siret de l'établissement", + description: "Origine de la creation de l'utilisateur (ex: Campagne mail, lien web, etc...) pour suivi", }, - opco: { + siret: { type: String, - default: null, - description: "Information sur l'opco de l'entreprise", - }, - idcc: { - type: String, - description: "Identifiant convention collective de l'entreprise", + description: "Siret de l'établissement", }, - establishment_raison_sociale: { + raison_sociale: { type: String, description: "Raison social de l'établissement", }, - establishment_enseigne: { + enseigne: { type: String, default: null, description: "Enseigne de l'établissement", }, - address_detail: { - type: Object, - description: "Detail de l'adresse de l'établissement", + idcc: { + type: String, + description: "Identifiant convention collective de l'entreprise", }, address: { type: String, description: "Adresse de l'établissement", }, + address_detail: { + type: Object, + description: "Detail de l'adresse de l'établissement", + }, geo_coordinates: { type: String, default: null, description: "Latitude/Longitude de l'adresse de l'entreprise", }, - establishment_id: { + opco: { type: String, - description: "Si l'utilisateur est une entreprise, l'objet doit contenir un identifiant de formulaire unique", + default: null, + description: "Information sur l'opco de l'entreprise", }, - origin: { + establishment_id: { type: String, - description: "Origine de la creation de l'utilisateur (ex: Campagne mail, lien web, etc...) pour suivi", + description: "Si l'utilisateur est une entreprise, l'objet doit contenir un identifiant de formulaire unique", }, }, { diff --git a/server/src/common/model/schema/multiCompte/roleManagement.schema.ts b/server/src/common/model/schema/multiCompte/roleManagement.schema.ts index 953b11aaee..d2cac09af3 100644 --- a/server/src/common/model/schema/multiCompte/roleManagement.schema.ts +++ b/server/src/common/model/schema/multiCompte/roleManagement.schema.ts @@ -15,7 +15,7 @@ const roleManagementEventSchema = new Schema( status: { type: String, enum: Object.values(AccessStatus), - description: "Statut de l'utilisateur", + description: "Statut de l'accès", }, reason: { type: String, @@ -37,31 +37,26 @@ const roleManagementEventSchema = new Schema( const roleManagementSchema = new Schema( { - accessor_id: { + origin: { type: String, - description: "ID de l'entité ayant accès", + description: "Origine de la creation", }, - accessor_type: { - type: String, - enum: Object.values(AccessEntityType), - description: "Type de l'entité ayant accès", + status: { + type: [roleManagementEventSchema], + description: "Evénements liés au cycle de vie de l'accès", }, - accessed_id: { + authorized_id: { type: String, description: "ID de l'entité sur laquelle l'accès est exercé", }, - accessed_type: { + authorized_type: { type: String, enum: Object.values(AccessEntityType), description: "Type de l'entité sur laquelle l'accès est exercé", }, - history: { - type: [roleManagementEventSchema], - description: "Evénements liés au cycle de vie de l'accès", - }, - origin: { + user_id: { type: String, - description: "Origine de la creation", + description: "ID de l'utilisateur ayant accès", }, }, { diff --git a/server/src/jobs/jobs.ts b/server/src/jobs/jobs.ts index 8505ff1368..415664ba8e 100644 --- a/server/src/jobs/jobs.ts +++ b/server/src/jobs/jobs.ts @@ -50,6 +50,7 @@ import updateGeoLocations from "./lbb/updateGeoLocations" import updateLbaCompanies from "./lbb/updateLbaCompanies" import updateOpcoCompanies from "./lbb/updateOpcoCompanies" import { runGarbageCollector } from "./misc/runGarbageCollector" +import { migrationUsers } from "./multiCompte/migrationUsers" import { activateOptoutOnEtablissementAndUpdateReferrersOnETFA } from "./rdv/activateOptoutOnEtablissementAndUpdateReferrersOnETFA" import { anonimizeAppointments } from "./rdv/anonymizeAppointments" import { anonymizeUsers } from "./rdv/anonymizeUsers" @@ -378,6 +379,9 @@ export async function runJob(job: IInternalJobsCronTask | IInternalJobsSimple): const { parallelism } = job.payload return importReferentielOpcoFromConstructys(parseInt(parallelism)) } + case "migrate-multi-compte": { + return migrationUsers() + } case "prdv:emails:resend": { const { fromDate } = job.payload return repriseEmailRdvs({ fromDateStr: fromDate }) diff --git a/server/src/jobs/multiCompte/migrationUsers.ts b/server/src/jobs/multiCompte/migrationUsers.ts index 34b2ba1f21..b1b45ec3a4 100644 --- a/server/src/jobs/multiCompte/migrationUsers.ts +++ b/server/src/jobs/multiCompte/migrationUsers.ts @@ -98,9 +98,6 @@ const migrationRecruteurs = async () => { if (!establishment_siret) { throw new Error("inattendu pour une ENTERPRISE: pas de establishment_siret") } - if (!address_detail) { - throw new Error("inattendu pour une ENTERPRISE: pas de address_detail") - } const entreprise: IEntreprise = { _id: userRecruteur._id, origin, @@ -134,11 +131,11 @@ const migrationRecruteurs = async () => { } const cfa: ICFA = { _id: userRecruteur._id, - establishment_siret, + siret: establishment_siret, address, address_detail, - establishment_enseigne, - establishment_raison_sociale, + enseigne: establishment_enseigne, + raison_sociale: establishment_raison_sociale, geo_coordinates, origin, createdAt, diff --git a/shared/models/cfa.model.ts b/shared/models/cfa.model.ts index cb5c29fe27..6f487f62bc 100644 --- a/shared/models/cfa.model.ts +++ b/shared/models/cfa.model.ts @@ -8,15 +8,15 @@ import { zObjectId } from "./common" export const zCFA = z .object({ _id: zObjectId, - establishment_siret: z.string(), - establishment_raison_sociale: z.string().nullish(), - establishment_enseigne: z.string().nullish(), - address_detail: ZGlobalAddress.nullish(), - address: z.string().nullish(), - geo_coordinates: z.string().nullish(), origin: z.string().nullish(), createdAt: z.date(), updatedAt: z.date(), + siret: z.string(), + raison_sociale: z.string().nullish(), + enseigne: z.string().nullish(), + address_detail: ZGlobalAddress.nullish(), + address: z.string().nullish(), + geo_coordinates: z.string().nullish(), }) .strict() diff --git a/shared/models/entreprise.model.ts b/shared/models/entreprise.model.ts index c13e3573bc..a0764fdbaa 100644 --- a/shared/models/entreprise.model.ts +++ b/shared/models/entreprise.model.ts @@ -8,19 +8,19 @@ import { zObjectId } from "./common" export const ZEntreprise = z .object({ _id: zObjectId, - origin: z.string().nullish(), + origin: z.string().nullish().describe("Origine de la creation de l'utilisateur (ex: Campagne mail, lien web, etc...) pour suivi"), createdAt: z.date(), updatedAt: z.date(), - siret: z.string(), - raison_sociale: z.string().nullish(), - enseigne: z.string().nullish(), - idcc: z.string().nullish(), - address: z.string().nullish(), - address_detail: ZGlobalAddress.nullish(), - geo_coordinates: z.string().nullish(), - opco: z.string().nullish(), + siret: z.string().describe("Siret de l'établissement"), + raison_sociale: z.string().nullish().describe("Raison sociale de l'établissement"), + enseigne: z.string().nullish().describe("Enseigne de l'établissement"), + idcc: z.string().nullish().describe("Identifiant convention collective de l'entreprise"), + address: z.string().nullish().describe("Adresse de l'établissement"), + address_detail: ZGlobalAddress.nullish().describe("Detail de l'adresse de l'établissement"), + geo_coordinates: z.string().nullish().describe("Latitude/Longitude de l'adresse de l'entreprise"), + opco: z.string().nullish().describe("Opco de l'entreprise"), - establishment_id: z.string().nullish(), + establishment_id: z.string().nullish().describe("Si l'utilisateur est une entreprise, l'objet doit contenir un identifiant de formulaire unique"), }) .strict() diff --git a/shared/models/roleManagement.model.ts b/shared/models/roleManagement.model.ts index 370948d774..8428adebab 100644 --- a/shared/models/roleManagement.model.ts +++ b/shared/models/roleManagement.model.ts @@ -20,11 +20,11 @@ export enum AccessStatus { export const ZRoleManagementEvent = z .object({ - validation_type: ZValidationUtilisateur, - status: enumToZod(AccessStatus), - reason: z.string(), - date: z.date(), - granted_by: z.string().nullish(), + validation_type: ZValidationUtilisateur.describe("Indique si l'action est ordonnée par un utilisateur ou le serveur"), + status: enumToZod(AccessStatus).describe("Statut de l'accès"), + reason: z.string().describe("Raison du changement de statut"), + date: z.date().describe("Date de l'évènement"), + granted_by: z.string().nullish().describe("Utilisateur à l'origine du changement"), }) .strict() @@ -33,11 +33,11 @@ export const ZAccessEntityType = enumToZod(AccessEntityType) export const ZRoleManagement = z .object({ _id: zObjectId, - origin: z.string(), - status: z.array(ZRoleManagementEvent), - authorized_id: z.string(), - authorized_type: ZAccessEntityType, - user_id: zObjectId, + origin: z.string().describe("Origine de la creation"), + status: z.array(ZRoleManagementEvent).describe("Evénements liés au cycle de vie de l'accès"), + authorized_id: z.string().describe("ID de l'entité sur laquelle l'accès est exercé"), + authorized_type: ZAccessEntityType.describe("Type de l'entité sur laquelle l'accès est exercé"), + user_id: zObjectId.describe("ID de l'utilisateur ayant accès"), createdAt: z.date(), updatedAt: z.date(), }) From 4ddd68dcd81582d05b802ab4a6f57d12531018fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9my=20Auricoste?= Date: Thu, 22 Feb 2024 18:04:03 +0100 Subject: [PATCH 06/78] fix: import schemas --- server/src/common/model/index.ts | 8 +++++ .../model/schema/multiCompte/cfa.schema.ts | 2 +- .../schema/multiCompte/entreprise.schema.ts | 2 +- .../multiCompte/roleManagement.schema.ts | 2 +- .../model/schema/multiCompte/user2.schema.ts | 2 +- server/src/jobs/multiCompte/migrationUsers.ts | 36 +++++++++---------- 6 files changed, 30 insertions(+), 22 deletions(-) diff --git a/server/src/common/model/index.ts b/server/src/common/model/index.ts index 1f06eaf1c0..5bbe1e5a52 100644 --- a/server/src/common/model/index.ts +++ b/server/src/common/model/index.ts @@ -25,6 +25,10 @@ import InternalJobs from "./schema/internalJobs/internalJobs.schema" import Job from "./schema/jobs/jobs.schema" import LbaCompany from "./schema/lbaCompany/lbaCompany.schema" import LbaCompanyLegacy from "./schema/lbaCompanylegacy/lbaCompanyLegacy.schema" +import { Cfa } from "./schema/multiCompte/cfa.schema" +import { Entreprise } from "./schema/multiCompte/entreprise.schema" +import { RoleManagement } from "./schema/multiCompte/roleManagement.schema" +import { User2 } from "./schema/multiCompte/user2.schema" import Opco from "./schema/opco/opco.schema" import Optout from "./schema/optout/optout.schema" import Recruiter from "./schema/recruiter/recruiter.schema" @@ -112,4 +116,8 @@ export { User, UserRecruteur, eligibleTrainingsForAppointmentHistory, + User2, + Entreprise, + Cfa, + RoleManagement, } diff --git a/server/src/common/model/schema/multiCompte/cfa.schema.ts b/server/src/common/model/schema/multiCompte/cfa.schema.ts index 5764668edd..17a8dedf16 100644 --- a/server/src/common/model/schema/multiCompte/cfa.schema.ts +++ b/server/src/common/model/schema/multiCompte/cfa.schema.ts @@ -43,4 +43,4 @@ const cfaSchema = new Schema( } ) -export const cfaRepository = buildMongooseModel(cfaSchema, "cfa") +export const Cfa = buildMongooseModel(cfaSchema, "cfa") diff --git a/server/src/common/model/schema/multiCompte/entreprise.schema.ts b/server/src/common/model/schema/multiCompte/entreprise.schema.ts index a0fa4c7374..9edbe32212 100644 --- a/server/src/common/model/schema/multiCompte/entreprise.schema.ts +++ b/server/src/common/model/schema/multiCompte/entreprise.schema.ts @@ -55,4 +55,4 @@ const entrepriseSchema = new Schema( } ) -export const entrepriseRepository = buildMongooseModel(entrepriseSchema, "entreprise") +export const Entreprise = buildMongooseModel(entrepriseSchema, "entreprise") diff --git a/server/src/common/model/schema/multiCompte/roleManagement.schema.ts b/server/src/common/model/schema/multiCompte/roleManagement.schema.ts index d2cac09af3..5a286d70cd 100644 --- a/server/src/common/model/schema/multiCompte/roleManagement.schema.ts +++ b/server/src/common/model/schema/multiCompte/roleManagement.schema.ts @@ -64,4 +64,4 @@ const roleManagementSchema = new Schema( } ) -export const roleManagementRepository = buildMongooseModel(roleManagementSchema, "roleManagement") +export const RoleManagement = buildMongooseModel(roleManagementSchema, "roleManagement") diff --git a/server/src/common/model/schema/multiCompte/user2.schema.ts b/server/src/common/model/schema/multiCompte/user2.schema.ts index 4c85613318..9d084b75d8 100644 --- a/server/src/common/model/schema/multiCompte/user2.schema.ts +++ b/server/src/common/model/schema/multiCompte/user2.schema.ts @@ -76,4 +76,4 @@ const User2Schema = new Schema( } ) -export const user2Repository = buildMongooseModel(User2Schema, "user2") +export const User2 = buildMongooseModel(User2Schema, "user2") diff --git a/server/src/jobs/multiCompte/migrationUsers.ts b/server/src/jobs/multiCompte/migrationUsers.ts index b1b45ec3a4..00dbc4019a 100644 --- a/server/src/jobs/multiCompte/migrationUsers.ts +++ b/server/src/jobs/multiCompte/migrationUsers.ts @@ -10,19 +10,19 @@ import { IUserRecruteur } from "shared/models/usersRecruteur.model.js" import { logger } from "../../common/logger.js" import { Appointment, User, UserRecruteur } from "../../common/model/index.js" -import { cfaRepository } from "../../common/model/schema/multiCompte/cfa.schema.js" -import { entrepriseRepository } from "../../common/model/schema/multiCompte/entreprise.schema.js" -import { roleManagementRepository } from "../../common/model/schema/multiCompte/roleManagement.schema.js" -import { user2Repository } from "../../common/model/schema/multiCompte/user2.schema.js" +import { Cfa } from "../../common/model/schema/multiCompte/cfa.schema.js" +import { Entreprise } from "../../common/model/schema/multiCompte/entreprise.schema.js" +import { RoleManagement } from "../../common/model/schema/multiCompte/roleManagement.schema.js" +import { User2 } from "../../common/model/schema/multiCompte/user2.schema.js" import { asyncForEachGrouped } from "../../common/utils/asyncUtils.js" import { parseEnumOrError } from "../../common/utils/enumUtils.js" import { notifyToSlack } from "../../common/utils/slackUtils.js" export const migrationUsers = async () => { - await user2Repository.deleteMany({}) - await entrepriseRepository.deleteMany({}) - await cfaRepository.deleteMany({}) - await roleManagementRepository.deleteMany({}) + await User2.deleteMany({}) + await Entreprise.deleteMany({}) + await Cfa.deleteMany({}) + await RoleManagement.deleteMany({}) const now = new Date() await migrationRecruteurs() await migrationCandidats(now) @@ -92,7 +92,7 @@ const migrationRecruteurs = async () => { origin, status: newStatus, } - await user2Repository.create(newUser) + await User2.create(newUser) stats.userCreated++ if (type === ENTREPRISE) { if (!establishment_siret) { @@ -113,7 +113,7 @@ const migrationRecruteurs = async () => { createdAt, updatedAt, } - const createdEntreprise = await entrepriseRepository.create(entreprise) + const createdEntreprise = await Entreprise.create(entreprise) stats.entrepriseCreated++ const roleManagement: Omit = { user_id: userRecruteur._id, @@ -124,7 +124,7 @@ const migrationRecruteurs = async () => { origin, status: userRecruteurStatusToRoleManagementStatus(oldStatus), } - await roleManagementRepository.create(roleManagement) + await RoleManagement.create(roleManagement) } else if (type === "CFA") { if (!establishment_siret) { throw new Error("inattendu pour un CFA: pas de establishment_siret") @@ -141,7 +141,7 @@ const migrationRecruteurs = async () => { createdAt, updatedAt, } - const createdCfa = await cfaRepository.create(cfa) + const createdCfa = await Cfa.create(cfa) stats.cfaCreated++ const roleManagement: Omit = { user_id: userRecruteur._id, @@ -152,7 +152,7 @@ const migrationRecruteurs = async () => { origin, status: userRecruteurStatusToRoleManagementStatus(oldStatus), } - await roleManagementRepository.create(roleManagement) + await RoleManagement.create(roleManagement) } else if (type === "ADMIN") { const roleManagement: Omit = { user_id: userRecruteur._id, @@ -163,7 +163,7 @@ const migrationRecruteurs = async () => { origin, status: userRecruteurStatusToRoleManagementStatus(oldStatus), } - await roleManagementRepository.create(roleManagement) + await RoleManagement.create(roleManagement) stats.adminAccess++ } else if (type === "OPCO") { const opco = parseEnumOrError(OPCOS, scope ?? null) @@ -176,7 +176,7 @@ const migrationRecruteurs = async () => { origin, status: userRecruteurStatusToRoleManagementStatus(oldStatus), } - await roleManagementRepository.create(roleManagement) + await RoleManagement.create(roleManagement) stats.opcoAccess++ } stats.success++ @@ -218,11 +218,11 @@ const migrationCandidats = async (now: Date) => { if (type) { await Appointment.updateMany({ applicant_id: candidat._id }, { $set: { applicant_type: parseEnumOrError(AppointmentUserType, type) } }) } - const existingUser = await user2Repository.findOne({ email }) + const existingUser = await User2.findOne({ email }) if (existingUser) { await Appointment.updateMany({ applicant_id: candidat._id }, { $set: { applicant_id: existingUser._id } }) if (dayjs(candidat.last_action_date).isAfter(existingUser.last_action_date)) { - await user2Repository.updateOne({ _id: existingUser._id }, { last_action_date: candidat.last_action_date }) + await User2.updateOne({ _id: existingUser._id }, { last_action_date: candidat.last_action_date }) } stats.alreadyExist++ return @@ -246,7 +246,7 @@ const migrationCandidats = async (now: Date) => { }, ], } - await user2Repository.create(newUser) + await User2.create(newUser) stats.success++ } catch (err) { logger.error(`erreur lors de l'import du user candidat avec id=${_id}`) From 9b73cdd08e47ba335b67339c58c5fd64b75a8189 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9my=20Auricoste?= Date: Mon, 26 Feb 2024 11:02:50 +0100 Subject: [PATCH 07/78] fix: renommage des champs first_name et last_name --- .../src/common/model/schema/multiCompte/user2.schema.ts | 4 ++-- server/src/jobs/multiCompte/migrationUsers.ts | 8 ++++---- shared/models/user2.model.ts | 4 ++-- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/server/src/common/model/schema/multiCompte/user2.schema.ts b/server/src/common/model/schema/multiCompte/user2.schema.ts index 9d084b75d8..6a60ff5d5a 100644 --- a/server/src/common/model/schema/multiCompte/user2.schema.ts +++ b/server/src/common/model/schema/multiCompte/user2.schema.ts @@ -45,12 +45,12 @@ const User2Schema = new Schema( type: [userStatusEventSchema], description: "Evénements liés au cycle de vie de l'utilisateur", }, - firstname: { + first_name: { type: String, default: null, description: "Le prénom", }, - lastname: { + last_name: { type: String, default: null, description: "Le nom", diff --git a/server/src/jobs/multiCompte/migrationUsers.ts b/server/src/jobs/multiCompte/migrationUsers.ts index 00dbc4019a..2bdbeff973 100644 --- a/server/src/jobs/multiCompte/migrationUsers.ts +++ b/server/src/jobs/multiCompte/migrationUsers.ts @@ -82,8 +82,8 @@ const migrationRecruteurs = async () => { }) const newUser: IUser2 = { _id: userRecruteur._id, - firstname: first_name ?? "", - lastname: last_name ?? "", + first_name: first_name ?? "", + last_name: last_name ?? "", phone: phone ?? "", email, last_action_date: last_connection, @@ -229,8 +229,8 @@ const migrationCandidats = async (now: Date) => { } const newUser: IUser2 = { _id: candidat._id, - firstname, - lastname, + first_name: firstname, + last_name: lastname, phone, email, last_action_date: last_action_date, diff --git a/shared/models/user2.model.ts b/shared/models/user2.model.ts index d6c6e9d307..c10f244f31 100644 --- a/shared/models/user2.model.ts +++ b/shared/models/user2.model.ts @@ -30,8 +30,8 @@ export const ZUser2 = z _id: zObjectId, origin: z.string().nullish(), status: z.array(ZUserStatusEvent), - firstname: z.string(), - lastname: z.string(), + first_name: z.string(), + last_name: z.string(), email: z.string().email(), phone: extensions.phone(), last_action_date: z.date().nullish(), From b97c65221586d8bdfdfd4b623c86471a29803b63 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9my=20Auricoste?= Date: Mon, 26 Feb 2024 15:51:39 +0100 Subject: [PATCH 08/78] fix: lbac 1491 : debut de plug des nouvelles tables --- .../etablissementRecruteur.controller.ts | 8 +- .../lba_recruteur/formulaire/createUser.ts | 4 +- server/src/jobs/multiCompte/migrationUsers.ts | 30 +++- server/src/services/userRecruteur.service.ts | 155 +++++++++++++++--- shared/models/entreprise.model.ts | 20 +++ shared/models/enumToZod.ts | 4 +- shared/models/usersRecruteur.model.ts | 10 +- shared/utils/getLastStatusEvent.ts | 13 ++ 8 files changed, 204 insertions(+), 40 deletions(-) create mode 100644 shared/utils/getLastStatusEvent.ts diff --git a/server/src/http/controllers/etablissementRecruteur.controller.ts b/server/src/http/controllers/etablissementRecruteur.controller.ts index 253928de60..303d82d708 100644 --- a/server/src/http/controllers/etablissementRecruteur.controller.ts +++ b/server/src/http/controllers/etablissementRecruteur.controller.ts @@ -3,7 +3,7 @@ import { IUserRecruteur, toPublicUser, zRoutes } from "shared" import { BusinessErrorCodes } from "shared/constants/errorCodes" import { ETAT_UTILISATEUR, RECRUITER_STATUS } from "shared/constants/recruteur" -import { Recruiter, UserRecruteur } from "@/common/model" +import { Cfa, Recruiter, UserRecruteur } from "@/common/model" import { startSession } from "@/common/utils/session.service" import config from "@/config" import { getUserFromRequest } from "@/security/authenticationService" @@ -132,11 +132,11 @@ export default (server: Server) => { }, async (req, res) => { const { userRecruteurId } = req.params - const cfa = await UserRecruteur.findOne({ _id: userRecruteurId }).lean() + const cfa = await Cfa.findOne({ _id: userRecruteurId }).lean() if (!cfa) { throw Boom.notFound(`Aucun CFA ayant pour id ${userRecruteurId.toString()}`) } - const cfa_delegated_siret = cfa.establishment_siret + const cfa_delegated_siret = cfa.siret if (!cfa_delegated_siret) { throw Boom.internal(`inattendu : le cfa n'a pas de champ cfa_delegated_siret`) } @@ -250,7 +250,7 @@ export default (server: Server) => { }, async (req, res) => { const { _id, ...rest } = req.body - const exists = await UserRecruteur.findOne({ email: req.body.email?.toLocaleLowerCase(), _id: { $ne: _id } }) + const exists = await UserRecruteur.findOne({ email: req.body.email.toLocaleLowerCase(), _id: { $ne: _id } }) if (exists) { throw Boom.badRequest("L'adresse mail est déjà associée à un compte La bonne alternance.") } diff --git a/server/src/jobs/lba_recruteur/formulaire/createUser.ts b/server/src/jobs/lba_recruteur/formulaire/createUser.ts index 3108e81589..2e7ac8e252 100644 --- a/server/src/jobs/lba_recruteur/formulaire/createUser.ts +++ b/server/src/jobs/lba_recruteur/formulaire/createUser.ts @@ -1,4 +1,4 @@ -import { ETAT_UTILISATEUR } from "shared/constants/recruteur" +import { ETAT_UTILISATEUR, VALIDATION_UTILISATEUR } from "shared/constants/recruteur" import { IUserRecruteur } from "shared/models" import { logger } from "../../../common/logger" @@ -39,7 +39,7 @@ export const createUserFromCLI = async ( status: [ { status: ETAT_UTILISATEUR.VALIDE, - validation_type: "AUTOMATIQUE", + validation_type: VALIDATION_UTILISATEUR.AUTO, user: "SERVEUR", date: new Date(), }, diff --git a/server/src/jobs/multiCompte/migrationUsers.ts b/server/src/jobs/multiCompte/migrationUsers.ts index 2bdbeff973..5b83d1278c 100644 --- a/server/src/jobs/multiCompte/migrationUsers.ts +++ b/server/src/jobs/multiCompte/migrationUsers.ts @@ -3,9 +3,9 @@ import { AppointmentUserType } from "shared/constants/appointment.js" import { EApplicantRole } from "shared/constants/rdva.js" import { ENTREPRISE, ETAT_UTILISATEUR, OPCOS, VALIDATION_UTILISATEUR } from "shared/constants/recruteur.js" import { ICFA } from "shared/models/cfa.model.js" -import { IEntreprise } from "shared/models/entreprise.model.js" +import { EntrepriseStatus, IEntreprise, IEntrepriseStatusEvent } from "shared/models/entreprise.model.js" import { AccessEntityType, AccessStatus, IRoleManagement, IRoleManagementEvent } from "shared/models/roleManagement.model.js" -import { UserEventType, IUser2, IUserStatusEvent } from "shared/models/user2.model.js" +import { IUser2, IUserStatusEvent, UserEventType } from "shared/models/user2.model.js" import { IUserRecruteur } from "shared/models/usersRecruteur.model.js" import { logger } from "../../common/logger.js" @@ -112,6 +112,7 @@ const migrationRecruteurs = async () => { opco, createdAt, updatedAt, + status: userRecruteurStatusToEntrepriseStatus(oldStatus), } const createdEntreprise = await Entreprise.create(entreprise) stats.entrepriseCreated++ @@ -291,3 +292,28 @@ function userRecruteurStatusToRoleManagementStatus(allStatus: IUserRecruteur["st } }) } + +function userRecruteurStatusToEntrepriseStatus(allStatus: IUserRecruteur["status"]): IEntrepriseStatusEvent[] { + return allStatus.flatMap((statusEvent) => { + const { date, reason, status, user, validation_type } = statusEvent + const statusMapping: Record = { + [ETAT_UTILISATEUR.VALIDE]: EntrepriseStatus.VALIDE, + [ETAT_UTILISATEUR.ERROR]: EntrepriseStatus.ERROR, + [ETAT_UTILISATEUR.ATTENTE]: null, + [ETAT_UTILISATEUR.DESACTIVE]: null, + } + const entrepriseStatus = status ? statusMapping[status] : null + if (entrepriseStatus && date) { + const newEvent: IEntrepriseStatusEvent = { + date, + reason: reason ?? "", + validation_type: validation_type, + granted_by: user, + status: entrepriseStatus, + } + return [newEvent] + } else { + return [] + } + }) +} diff --git a/server/src/services/userRecruteur.service.ts b/server/src/services/userRecruteur.service.ts index d3d558ef47..c10d181a17 100644 --- a/server/src/services/userRecruteur.service.ts +++ b/server/src/services/userRecruteur.service.ts @@ -1,14 +1,18 @@ import { randomUUID } from "crypto" import Boom from "boom" -import type { FilterQuery, ModelUpdateOptions, UpdateQuery } from "mongoose" -import { IUserRecruteur, IUserRecruteurWritable, IUserStatusValidation, UserRecruteurForAdminProjection } from "shared" +import type { FilterQuery, ModelUpdateOptions, ObjectId, UpdateQuery } from "mongoose" +import { IUserRecruteur, IUserRecruteurWritable, IUserStatusValidation, UserRecruteurForAdminProjection, assertUnreachable } from "shared" import { CFA, ENTREPRISE, ETAT_UTILISATEUR, VALIDATION_UTILISATEUR } from "shared/constants/recruteur" +import { EntrepriseStatus, IEntrepriseStatusEvent } from "shared/models/entreprise.model" +import { AccessEntityType, AccessStatus, IRoleManagement } from "shared/models/roleManagement.model" +import { UserEventType } from "shared/models/user2.model" +import { getLastStatusEvent } from "shared/utils/getLastStatusEvent" import { entriesToTypedRecord, typedKeys } from "shared/utils/objectUtils" import { getStaticFilePath } from "@/common/utils/getStaticFilePath" -import { UserRecruteur } from "../common/model/index" +import { Cfa, Entreprise, RoleManagement, User2, UserRecruteur } from "../common/model/index" import config from "../config" import { createAuthMagicLink } from "./appLinks.service" @@ -22,33 +26,119 @@ import mailer, { sanitizeForEmail } from "./mailer.service" export const createApiKey = (): string => `mna-${randomUUID()}` /** - * @query get all user using a given query filter - * @param {Filter} query - * @param {Object} options - * @param {Object} pagination - * @param {Number} pagination.page - * @param {Number} pagination.limit - * @returns {Promise} + * @description get a single user using a given query filter */ -export const getUsers = async (query: FilterQuery, options, { page, limit }) => { - const response = await UserRecruteur.paginate({ query, ...options, page, limit, lean: true }) +export const getUser = async (query: FilterQuery): Promise => { + return UserRecruteur.findOne(query).lean() +} + +const entrepriseStatusEventToUserRecruteurStatusEvent = (entrepriseStatusEvent: IEntrepriseStatusEvent, forcedStatus: ETAT_UTILISATEUR): IUserStatusValidation => { + const { date, reason, validation_type, granted_by } = entrepriseStatusEvent return { - pagination: { - page: response?.page, - result_per_page: limit, - number_of_page: response?.totalPages, - total: response?.totalDocs, - }, - data: response?.docs, + date, + user: granted_by ?? "", + validation_type, + reason, + status: forcedStatus, } } -/** - * @description get a single user using a given query filter - * @param {Filter} query - * @returns {Promise} - */ -export const getUser = async (query: FilterQuery): Promise => UserRecruteur.findOne(query).lean() +const getOrganismeFromRole = async (role: IRoleManagement): Promise | null> => { + switch (role.authorized_type) { + case AccessEntityType.ENTREPRISE: { + const entreprise = await Entreprise.findOne({ _id: role.authorized_id }).lean() + if (!entreprise) return null + const { siret, address, address_detail, establishment_id, geo_coordinates, idcc, opco, origin, raison_sociale, enseigne, status } = entreprise + const lastStatus = getLastStatusEvent(status) + + return { + establishment_siret: siret, + establishment_enseigne: enseigne, + establishment_raison_sociale: raison_sociale, + address, + address_detail, + establishment_id, + geo_coordinates, + idcc, + opco, + origin, + type: ENTREPRISE, + status: lastStatus?.status === EntrepriseStatus.ERROR ? [entrepriseStatusEventToUserRecruteurStatusEvent(lastStatus, ETAT_UTILISATEUR.ERROR)] : [], + } + } + case AccessEntityType.CFA: { + const cfa = await Cfa.findOne({ _id: role.authorized_id }).lean() + if (!cfa) return null + const { siret, address, address_detail, geo_coordinates, origin, raison_sociale, enseigne } = cfa + return { + establishment_siret: siret, + establishment_enseigne: enseigne, + establishment_raison_sociale: raison_sociale, + address, + address_detail, + geo_coordinates, + origin, + type: CFA, + is_qualiopi: true, + } + } + default: + return null + } +} + +const roleStatusToUserRecruteurStatus = (roleStatus: AccessStatus): ETAT_UTILISATEUR => { + switch (roleStatus) { + case AccessStatus.GRANTED: + return ETAT_UTILISATEUR.VALIDE + case AccessStatus.DENIED: + return ETAT_UTILISATEUR.DESACTIVE + case AccessStatus.AWAITING_VALIDATION: + return ETAT_UTILISATEUR.ATTENTE + default: + assertUnreachable(roleStatus) + } +} + +export const getUserRecruteurById = async (id: ObjectId): Promise => { + const user = await User2.findById(id).lean() + if (!user) return null + const role = await RoleManagement.findOne({ user_id: id.toString() }).lean() + if (!role) return null + const organismeData = await getOrganismeFromRole(role) + const { email, first_name, last_name, phone, last_action_date, _id } = user + const oldStatus: IUserStatusValidation[] = [ + ...role.status.map(({ date, reason, status, validation_type, granted_by }) => { + const userRecruteurStatus = roleStatusToUserRecruteurStatus(status) + return { + date, + reason, + status: userRecruteurStatus, + validation_type, + user: granted_by ?? "", + } + }), + ...(organismeData?.status ?? []), + ] + const roleType = role.authorized_type === AccessEntityType.OPCO ? "OPCO" : role.authorized_type === AccessEntityType.ADMIN ? "ADMIN" : null + const organismeType = organismeData?.type + const type = roleType ?? organismeType ?? null + if (!type) throw Boom.internal("unexpected: no type found") + return { + ...organismeData, + createdAt: organismeData?.createdAt ?? user.createdAt, + updatedAt: organismeData?.updatedAt ?? user.updatedAt, + is_email_checked: user.status.some((event) => event.status === UserEventType.VALIDATION_EMAIL), + type, + _id, + email, + first_name, + last_name, + phone, + last_connection: last_action_date, + status: oldStatus, + } +} /** * @description création d'un nouveau user recruteur. Le champ status peut être passé ou, s'il n'est pas passé, être sauvé ultérieurement @@ -257,3 +347,18 @@ export const getErrorUsers = () => }) .select(projection) .lean() + +export const getUsersWithRoles = async () => { + const usersWithRoles = await RoleManagement.aggregate([ + { + $lookup: { + from: "user2", + localField: "user_id", + foreignField: "_id", + as: "roles", + }, + }, + ]) + console.log(usersWithRoles.slice(0, 3)) + return usersWithRoles +} diff --git a/shared/models/entreprise.model.ts b/shared/models/entreprise.model.ts index a0764fdbaa..8c0168f0ab 100644 --- a/shared/models/entreprise.model.ts +++ b/shared/models/entreprise.model.ts @@ -4,6 +4,25 @@ import { z } from "../helpers/zodWithOpenApi" import { ZGlobalAddress } from "./address.model" import { zObjectId } from "./common" +import { enumToZod } from "./enumToZod" +import { ZValidationUtilisateur } from "./user2.model" + +export enum EntrepriseStatus { + ERROR = "ERROR", + VALIDE = "VALIDE", +} + +export const ZEntrepriseStatusEvent = z + .object({ + validation_type: ZValidationUtilisateur.describe("Indique si l'action est ordonnée par un utilisateur ou le serveur"), + status: enumToZod(EntrepriseStatus).describe("Statut de l'accès"), + reason: z.string().describe("Raison du changement de statut"), + date: z.date().describe("Date de l'évènement"), + granted_by: z.string().nullish().describe("Utilisateur à l'origine du changement"), + }) + .strict() + +export type IEntrepriseStatusEvent = z.output export const ZEntreprise = z .object({ @@ -19,6 +38,7 @@ export const ZEntreprise = z address_detail: ZGlobalAddress.nullish().describe("Detail de l'adresse de l'établissement"), geo_coordinates: z.string().nullish().describe("Latitude/Longitude de l'adresse de l'entreprise"), opco: z.string().nullish().describe("Opco de l'entreprise"), + status: z.array(ZEntrepriseStatusEvent).describe("historique de la mise à jour des données entreprise"), establishment_id: z.string().nullish().describe("Si l'utilisateur est une entreprise, l'objet doit contenir un identifiant de formulaire unique"), }) diff --git a/shared/models/enumToZod.ts b/shared/models/enumToZod.ts index f14f6ea382..74bb2ecca0 100644 --- a/shared/models/enumToZod.ts +++ b/shared/models/enumToZod.ts @@ -1,6 +1,6 @@ import { z } from "../helpers/zodWithOpenApi" -export function enumToZod(enumObject: Record) { - const enumValues = Object.values(enumObject) as string[] +export function enumToZod(enumObject: Record): z.ZodEnum<[Value, ...Value[]]> { + const enumValues = Object.values(enumObject) return z.enum([enumValues[0], ...enumValues.slice(1)]) } diff --git a/shared/models/usersRecruteur.model.ts b/shared/models/usersRecruteur.model.ts index 4279c083f9..3ae1ccc9a1 100644 --- a/shared/models/usersRecruteur.model.ts +++ b/shared/models/usersRecruteur.model.ts @@ -1,21 +1,21 @@ import { Jsonify } from "type-fest" -import { AUTHTYPE, CFA, ETAT_UTILISATEUR } from "../constants/recruteur" +import { AUTHTYPE, CFA, ETAT_UTILISATEUR, VALIDATION_UTILISATEUR } from "../constants/recruteur" import { removeUrlsFromText } from "../helpers/common" import { extensions } from "../helpers/zodHelpers/zodPrimitives" import { z } from "../helpers/zodWithOpenApi" import { ZGlobalAddress, ZPointGeometry } from "./address.model" import { zObjectId } from "./common" +import { enumToZod } from "./enumToZod" -const etatUtilisateurValues = Object.values(ETAT_UTILISATEUR) -export const ZEtatUtilisateur = z.enum([etatUtilisateurValues[0], ...etatUtilisateurValues.slice(1)]).describe("Statut de l'utilisateur") +export const ZEtatUtilisateur = enumToZod(ETAT_UTILISATEUR).describe("Statut de l'utilisateur") const authTypeValues = Object.values(AUTHTYPE) export const ZUserStatusValidation = z .object({ - validation_type: z.enum(["AUTOMATIQUE", "MANUELLE"]).describe("Processus de validation lors de l'inscription de l'utilisateur"), + validation_type: enumToZod(VALIDATION_UTILISATEUR).describe("Processus de validation lors de l'inscription de l'utilisateur"), // TODO : check DB and remove nullish status: ZEtatUtilisateur.nullish(), reason: z.string().nullish().describe("Raison du changement de statut"), @@ -119,7 +119,7 @@ export const ZUserRecruteurPublic = ZUserRecruteur.pick({ }).extend({ is_delegated: z.boolean(), cfa_delegated_siret: extensions.siret.nullish(), - status_current: z.enum([etatUtilisateurValues[0], ...etatUtilisateurValues.slice(1)]).nullish(), + status_current: ZEtatUtilisateur.nullish(), }) export type IUserRecruteurPublic = Jsonify> diff --git a/shared/utils/getLastStatusEvent.ts b/shared/utils/getLastStatusEvent.ts new file mode 100644 index 0000000000..1bfaa3e0dd --- /dev/null +++ b/shared/utils/getLastStatusEvent.ts @@ -0,0 +1,13 @@ +export const getLastStatusEvent = (stateArray?: T[]): T | null => { + if (!stateArray) { + return null + } + const sortedArray = [...stateArray].sort((a, b) => { + return new Date(a?.date ?? 0).valueOf() - new Date(b?.date ?? 0).valueOf() + }) + const lastValidationEvent = sortedArray.at(sortedArray.length - 1) + if (!lastValidationEvent) { + return null + } + return lastValidationEvent.status ? lastValidationEvent : null +} From 19fabc00f55507e53bf7e98ce8b44f89fef19184 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9my=20Auricoste?= Date: Mon, 26 Feb 2024 17:35:31 +0100 Subject: [PATCH 09/78] fix: clean --- shared/models/usersRecruteur.model.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/shared/models/usersRecruteur.model.ts b/shared/models/usersRecruteur.model.ts index 3ae1ccc9a1..695f72003b 100644 --- a/shared/models/usersRecruteur.model.ts +++ b/shared/models/usersRecruteur.model.ts @@ -1,6 +1,6 @@ import { Jsonify } from "type-fest" -import { AUTHTYPE, CFA, ETAT_UTILISATEUR, VALIDATION_UTILISATEUR } from "../constants/recruteur" +import { AUTHTYPE, CFA, ETAT_UTILISATEUR } from "../constants/recruteur" import { removeUrlsFromText } from "../helpers/common" import { extensions } from "../helpers/zodHelpers/zodPrimitives" import { z } from "../helpers/zodWithOpenApi" @@ -8,6 +8,7 @@ import { z } from "../helpers/zodWithOpenApi" import { ZGlobalAddress, ZPointGeometry } from "./address.model" import { zObjectId } from "./common" import { enumToZod } from "./enumToZod" +import { ZValidationUtilisateur } from "./user2.model" export const ZEtatUtilisateur = enumToZod(ETAT_UTILISATEUR).describe("Statut de l'utilisateur") @@ -15,7 +16,7 @@ const authTypeValues = Object.values(AUTHTYPE) export const ZUserStatusValidation = z .object({ - validation_type: enumToZod(VALIDATION_UTILISATEUR).describe("Processus de validation lors de l'inscription de l'utilisateur"), + validation_type: ZValidationUtilisateur.describe("Processus de validation lors de l'inscription de l'utilisateur"), // TODO : check DB and remove nullish status: ZEtatUtilisateur.nullish(), reason: z.string().nullish().describe("Raison du changement de statut"), From 0c568cbebd739907734b7656f5d0cbbee202c82e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9my=20Auricoste?= Date: Mon, 26 Feb 2024 17:45:38 +0100 Subject: [PATCH 10/78] fix: refactor getUser to getUserRecruteurById and getUserRecruteurByEmail --- .../controllers/etablissementRecruteur.controller.ts | 4 ++-- server/src/http/controllers/formulaire.controller.ts | 3 +-- server/src/http/controllers/login.controller.ts | 8 ++++---- .../src/jobs/lba_recruteur/formulaire/createUser.ts | 4 ++-- server/src/security/accessTokenService.ts | 4 ++-- server/src/security/authenticationService.ts | 4 ++-- server/src/services/etablissement.service.ts | 4 ++-- server/src/services/userRecruteur.service.ts | 11 +++++++---- 8 files changed, 22 insertions(+), 20 deletions(-) diff --git a/server/src/http/controllers/etablissementRecruteur.controller.ts b/server/src/http/controllers/etablissementRecruteur.controller.ts index 303d82d708..edb15cbf9c 100644 --- a/server/src/http/controllers/etablissementRecruteur.controller.ts +++ b/server/src/http/controllers/etablissementRecruteur.controller.ts @@ -26,7 +26,7 @@ import { import { autoValidateUser, createUser, - getUser, + getUserRecruteurByEmail, getUserStatus, sendWelcomeEmailToUserRecruteur, setUserHasToBeManuallyValidated, @@ -170,7 +170,7 @@ export default (server: Server) => { const { email, establishment_siret } = req.body const formatedEmail = email.toLocaleLowerCase() // check if user already exist - const userRecruteurOpt = await getUser({ email: formatedEmail }) + const userRecruteurOpt = await getUserRecruteurByEmail(formatedEmail) if (userRecruteurOpt) { throw Boom.forbidden("L'adresse mail est déjà associée à un compte La bonne alternance.") } diff --git a/server/src/http/controllers/formulaire.controller.ts b/server/src/http/controllers/formulaire.controller.ts index 3283765926..9589386398 100644 --- a/server/src/http/controllers/formulaire.controller.ts +++ b/server/src/http/controllers/formulaire.controller.ts @@ -21,7 +21,6 @@ import { provideOffre, updateFormulaire, } from "../../services/formulaire.service" -import { getUser } from "../../services/userRecruteur.service" import { Server } from "../server" export default (server: Server) => { @@ -105,7 +104,7 @@ export default (server: Server) => { async (req, res) => { const { userId: userRecruteurId } = req.params const { establishment_siret, email, last_name, first_name, phone, opco, idcc } = req.body - const userRecruteurOpt = await getUser({ _id: userRecruteurId }) + const userRecruteurOpt = await getUserRecruteurById(userRecruteurId) if (!userRecruteurOpt) { throw Boom.badRequest("Nous n'avons pas trouvé votre compte utilisateur") } diff --git a/server/src/http/controllers/login.controller.ts b/server/src/http/controllers/login.controller.ts index b0eedf7318..759652c430 100644 --- a/server/src/http/controllers/login.controller.ts +++ b/server/src/http/controllers/login.controller.ts @@ -11,7 +11,7 @@ import config from "../../config" import { sendUserConfirmationEmail } from "../../services/etablissement.service" import { controlUserState } from "../../services/login.service" import mailer, { sanitizeForEmail } from "../../services/mailer.service" -import { getUser, updateLastConnectionDate } from "../../services/userRecruteur.service" +import { getUserRecruteurByEmail, getUserRecruteurById, updateLastConnectionDate } from "../../services/userRecruteur.service" import { Server } from "../server" export default (server: Server) => { @@ -23,7 +23,7 @@ export default (server: Server) => { }, async (req, res) => { const { userId } = req.params - const user = await getUser({ _id: userId }) + const user = await getUserRecruteurById(userId) if (!user) { return res.status(400).send({ error: true, reason: "UNKNOWN" }) } @@ -44,7 +44,7 @@ export default (server: Server) => { async (req, res) => { const { email } = req.body const formatedEmail = email.toLowerCase() - const user = await getUser({ email: formatedEmail }) + const user = await getUserRecruteurByEmail(formatedEmail) if (!user) { return res.status(400).send({ error: true, reason: "UNKNOWN" }) @@ -93,7 +93,7 @@ export default (server: Server) => { const { email } = user.identity const formatedEmail = email.toLowerCase() - const userData = await getUser({ email: formatedEmail }) + const userData = await getUserRecruteurByEmail(formatedEmail) if (!userData) { throw Boom.unauthorized() diff --git a/server/src/jobs/lba_recruteur/formulaire/createUser.ts b/server/src/jobs/lba_recruteur/formulaire/createUser.ts index 2e7ac8e252..15c6d2a4b9 100644 --- a/server/src/jobs/lba_recruteur/formulaire/createUser.ts +++ b/server/src/jobs/lba_recruteur/formulaire/createUser.ts @@ -2,7 +2,7 @@ import { ETAT_UTILISATEUR, VALIDATION_UTILISATEUR } from "shared/constants/recru import { IUserRecruteur } from "shared/models" import { logger } from "../../../common/logger" -import { getUser, createUser } from "../../../services/userRecruteur.service" +import { createUser, getUserRecruteurByEmail } from "../../../services/userRecruteur.service" export const createUserFromCLI = async ( { @@ -18,7 +18,7 @@ export const createUserFromCLI = async ( { options }: { options: { Type: IUserRecruteur["type"]; Email_valide: IUserRecruteur["is_email_checked"] } } ) => { const { Type, Email_valide } = options - const exist = await getUser({ email }) + const exist = await getUserRecruteurByEmail(email) if (exist) { logger.error(`Users ${email} already exist - ${exist._id}`) diff --git a/server/src/security/accessTokenService.ts b/server/src/security/accessTokenService.ts index 1669baae4b..fe5045579f 100644 --- a/server/src/security/accessTokenService.ts +++ b/server/src/security/accessTokenService.ts @@ -11,7 +11,7 @@ import { sentryCaptureException } from "@/common/utils/sentryUtils" import config from "@/config" import { controlUserState } from "../services/login.service" -import { getUser } from "../services/userRecruteur.service" +import { getUserRecruteurById } from "../services/userRecruteur.service" // cf https://www.sistrix.com/ask-sistrix/technical-seo/site-structure/url-length-how-long-can-a-url-be const INTERNET_EXPLORER_V10_MAX_LENGTH = 2083 @@ -192,7 +192,7 @@ export async function parseAccessToken( }) const token = data.payload as IAccessToken if (token.identity.type === "IUserRecruteur") { - const user = await getUser({ _id: token.identity._id }) + const user = await getUserRecruteurById(token.identity._id) if (!user) throw Boom.unauthorized() diff --git a/server/src/security/authenticationService.ts b/server/src/security/authenticationService.ts index 7b882518f3..50c4347163 100644 --- a/server/src/security/authenticationService.ts +++ b/server/src/security/authenticationService.ts @@ -11,7 +11,7 @@ import { UserWithType } from "shared/security/permissions" import { Credential } from "@/common/model" import config from "@/config" import { getSession } from "@/services/sessions.service" -import { getUser as getUserRecruteur, updateLastConnectionDate } from "@/services/userRecruteur.service" +import { getUserRecruteurByEmail, updateLastConnectionDate } from "@/services/userRecruteur.service" import { controlUserState } from "../services/login.service" @@ -56,7 +56,7 @@ async function authCookieSession(req: FastifyRequest): Promise => { - const user = await User2.findById(id).lean() +export const getUserRecruteurById = (id: string | ObjectId) => getUserRecruteurByUser2Query({ _id: id }) +export const getUserRecruteurByEmail = (email: string) => getUserRecruteurByUser2Query({ email }) + +const getUserRecruteurByUser2Query = async (user2query: Partial): Promise => { + const user = await User2.findOne(user2query).lean() if (!user) return null - const role = await RoleManagement.findOne({ user_id: id.toString() }).lean() + const role = await RoleManagement.findOne({ user_id: user._id.toString() }).lean() if (!role) return null const organismeData = await getOrganismeFromRole(role) const { email, first_name, last_name, phone, last_action_date, _id } = user From 7c03aa48ffc6472c298f1a579b56edf35703ebc3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9my=20Auricoste?= Date: Mon, 26 Feb 2024 18:15:10 +0100 Subject: [PATCH 11/78] =?UTF-8?q?fix:=20suppression=20jobs=20obsol=C3=A8te?= =?UTF-8?q?s=20+=20remplacement=20simple=20de=20UserRecruteur.find?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/src/commands.ts | 13 ---- .../http/controllers/formulaire.controller.ts | 1 + .../src/http/controllers/user.controller.ts | 9 +-- .../jobs/anonymization/anonymizeIndividual.ts | 4 +- server/src/jobs/jobs.ts | 6 -- .../formulaire/misc/UpdateEmailToLowerCase.ts | 49 --------------- .../formulaire/misc/recoverGeocoordinates.ts | 56 ------------------ .../misc/fixUserRecruteurCfaDataValidation.ts | 59 ------------------- server/src/security/authorisationService.ts | 3 +- server/src/services/etablissement.service.ts | 11 +--- server/src/services/formulaire.service.ts | 4 +- server/src/services/lbajob.service.ts | 4 +- server/src/services/user.service.ts | 4 +- server/src/services/userRecruteur.service.ts | 7 ++- 14 files changed, 23 insertions(+), 207 deletions(-) delete mode 100644 server/src/jobs/lba_recruteur/formulaire/misc/UpdateEmailToLowerCase.ts delete mode 100644 server/src/jobs/lba_recruteur/formulaire/misc/recoverGeocoordinates.ts delete mode 100644 server/src/jobs/lba_recruteur/user/misc/fixUserRecruteurCfaDataValidation.ts diff --git a/server/src/commands.ts b/server/src/commands.ts index b28b1806ca..c82319466b 100644 --- a/server/src/commands.ts +++ b/server/src/commands.ts @@ -219,13 +219,6 @@ program .option("-q, --queued", "Run job asynchronously", false) .action(createJobAction("recruiters:get-missing-address-detail")) -// Temporaire, one shot à executer en recette et prod -program - .command("migration:get-missing-geocoords") - .description("Récupération des geocoordonnées manquautes") - .option("-q, --queued", "Run job asynchronously", false) - .action(createJobAction("migration:get-missing-geocoords")) - // Temporaire, one shot à executer en recette et prod program.command("import:rome").description("import référentiel fiche metier rome v3").option("-q, --queued", "Run job asynchronously", false).action(createJobAction("import:rome")) // Temporaire, one shot à executer en recette et prod @@ -581,12 +574,6 @@ program .option("-q, --queued", "Run job asynchronously", false) .action(createJobAction("user-recruters:data-validation:fix")) -program - .command("fix-data-validation-user-recruteurs-cfa") - .description("Répare les data des userrecruteurs CFA") - .option("-q, --queued", "Run job asynchronously", false) - .action(createJobAction("user-recruters-cfa:data-validation:fix")) - program .command("anonymize-user-recruteurs") .description("Anonymize les userrecruteurs qui ne se sont pas connectés depuis plus de 2 ans") diff --git a/server/src/http/controllers/formulaire.controller.ts b/server/src/http/controllers/formulaire.controller.ts index 9589386398..610d2d4c99 100644 --- a/server/src/http/controllers/formulaire.controller.ts +++ b/server/src/http/controllers/formulaire.controller.ts @@ -3,6 +3,7 @@ import { zRoutes } from "shared/index" import { UserRecruteur } from "@/common/model" import { generateOffreToken } from "@/services/appLinks.service" +import { getUserRecruteurById } from "@/services/userRecruteur.service" import { getApplicationsByJobId } from "../../services/application.service" import { entrepriseOnboardingWorkflow } from "../../services/etablissement.service" diff --git a/server/src/http/controllers/user.controller.ts b/server/src/http/controllers/user.controller.ts index c2d532b210..da9ea11084 100644 --- a/server/src/http/controllers/user.controller.ts +++ b/server/src/http/controllers/user.controller.ts @@ -19,6 +19,7 @@ import { getAwaitingUsers, getDisabledUsers, getErrorUsers, + getUserRecruteurById, removeUser, sendWelcomeEmailToUserRecruteur, updateUser, @@ -71,7 +72,7 @@ export default (server: Server) => { }, async (req, res) => { const { userId } = req.params - const userRecruteur = await UserRecruteur.findOne({ _id: userId }).lean() + const userRecruteur = await getUserRecruteurById(userId) let jobs: IJob[] = [] if (!userRecruteur) throw Boom.notFound(`user with id=${userId} not found`) @@ -165,7 +166,7 @@ export default (server: Server) => { onRequest: [server.auth(zRoutes.get["/user/:userId"])], }, async (req, res) => { - const user = await UserRecruteur.findOne({ _id: req.params.userId }).lean() + const user = await getUserRecruteurById(req.params.userId) const loggedUser = getUserFromRequest(req, zRoutes.get["/user/:userId"]).value let jobs: IJob[] = [] @@ -200,7 +201,7 @@ export default (server: Server) => { onRequest: [server.auth(zRoutes.get["/user/status/:userId"])], }, async (req, res) => { - const user = await UserRecruteur.findOne({ _id: req.params.userId }).lean() + const user = await getUserRecruteurById(req.params.userId) if (!user) throw Boom.notFound("User not found") const status_current = getUserStatus(user.status) @@ -217,7 +218,7 @@ export default (server: Server) => { onRequest: [server.auth(zRoutes.get["/user/status/:userId/by-token"])], }, async (req, res) => { - const user = await UserRecruteur.findOne({ _id: req.params.userId }).lean() + const user = await getUserRecruteurById(req.params.userId) if (!user) throw Boom.notFound("User not found") const status_current = getUserStatus(user.status) diff --git a/server/src/jobs/anonymization/anonymizeIndividual.ts b/server/src/jobs/anonymization/anonymizeIndividual.ts index 9ca57e5e0a..6ed6bfcbcc 100644 --- a/server/src/jobs/anonymization/anonymizeIndividual.ts +++ b/server/src/jobs/anonymization/anonymizeIndividual.ts @@ -1,6 +1,8 @@ import pkg from "mongodb" import { CFA, ENTREPRISE } from "shared/constants/recruteur" +import { getUserRecruteurById } from "@/services/userRecruteur.service" + import { logger } from "../../common/logger" import { AnonymizedUser, Application, Recruiter, User, UserRecruteur } from "../../common/model/index" @@ -116,7 +118,7 @@ const anonymizeUser = async (_id: string) => { } const anonymizeUserRecruterAndRecruiter = async (_id: string) => { - const user = await UserRecruteur.findById(_id).lean() + const user = await getUserRecruteurById(_id) if (!user) { throw new Error("Anonymize userRecruter not found") diff --git a/server/src/jobs/jobs.ts b/server/src/jobs/jobs.ts index 415664ba8e..a3715a3a5b 100644 --- a/server/src/jobs/jobs.ts +++ b/server/src/jobs/jobs.ts @@ -30,7 +30,6 @@ import { fixJobExpirationDate } from "./lba_recruteur/formulaire/fixJobExpiratio import { fixJobType } from "./lba_recruteur/formulaire/fixJobType" import { fixRecruiterDataValidation } from "./lba_recruteur/formulaire/fixRecruiterDataValidation" import { exportPE } from "./lba_recruteur/formulaire/misc/exportPE" -import { recoverMissingGeocoordinates } from "./lba_recruteur/formulaire/misc/recoverGeocoordinates" import { removeIsDelegatedFromJobs } from "./lba_recruteur/formulaire/misc/removeIsDelegatedFromJobs" import { repiseGeocoordinates } from "./lba_recruteur/formulaire/misc/repriseGeocoordinates" import { resendDelegationEmailWithAccessToken } from "./lba_recruteur/formulaire/misc/sendDelegationEmailWithSecuredToken" @@ -41,7 +40,6 @@ import { importReferentielOpcoFromConstructys } from "./lba_recruteur/opco/const import { relanceOpco } from "./lba_recruteur/opco/relanceOpco" import { createOffreCollection } from "./lba_recruteur/seed/createOffre" import { fillRecruiterRaisonSociale } from "./lba_recruteur/user/misc/fillRecruiterRaisonSociale" -import { fixUserRecruiterCfaDataValidation } from "./lba_recruteur/user/misc/fixUserRecruteurCfaDataValidation" import { fixUserRecruiterDataValidation } from "./lba_recruteur/user/misc/fixUserRecruteurDataValidation" import { checkAwaitingCompaniesValidation } from "./lba_recruteur/user/misc/updateMissingActivationState" import { updateSiretInfosInError } from "./lba_recruteur/user/misc/updateSiretInfosInError" @@ -254,8 +252,6 @@ export async function runJob(job: IInternalJobsCronTask | IInternalJobsSimple): return repiseGeocoordinates() case "recruiters:get-missing-address-detail": return updateAddressDetailOnRecruitersCollection() - case "migration:get-missing-geocoords": // Temporaire, doit tourner en recette et production - return recoverMissingGeocoordinates() case "import:rome": return importFicheMetierRomeV3() case "migration:remove-version-key-from-all-collections": // Temporaire, doit tourner en recette et production @@ -373,8 +369,6 @@ export async function runJob(job: IInternalJobsCronTask | IInternalJobsSimple): return fixRecruiterDataValidation() case "user-recruters:data-validation:fix": return fixUserRecruiterDataValidation() - case "user-recruters-cfa:data-validation:fix": - return fixUserRecruiterCfaDataValidation() case "referentiel-opco:constructys:import": { const { parallelism } = job.payload return importReferentielOpcoFromConstructys(parseInt(parallelism)) diff --git a/server/src/jobs/lba_recruteur/formulaire/misc/UpdateEmailToLowerCase.ts b/server/src/jobs/lba_recruteur/formulaire/misc/UpdateEmailToLowerCase.ts deleted file mode 100644 index b72c631684..0000000000 --- a/server/src/jobs/lba_recruteur/formulaire/misc/UpdateEmailToLowerCase.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { ETAT_UTILISATEUR, RECRUITER_STATUS, VALIDATION_UTILISATEUR } from "shared/constants/recruteur" - -import { logger } from "../../../../common/logger" -import { Recruiter, UserRecruteur } from "../../../../common/model/index" -import { asyncForEach } from "../../../../common/utils/asyncUtils" -import { runScript } from "../../../scriptWrapper" - -function hasUpperCase(str) { - return str !== str.toLowerCase() -} - -runScript(async () => { - const users = await UserRecruteur.find({}) - const userToUpdate = users.filter((x) => hasUpperCase(x.email)) - const stat = { hasSibblingLowerCase: 0, total: users.length } - - logger.info(`${userToUpdate.length} utilisateur à mettre à jour`) - - await asyncForEach(userToUpdate, async (user) => { - const exist = await UserRecruteur.findOne({ email: user.email.toLowerCase() }) - - if (exist) { - stat.hasSibblingLowerCase++ - - await UserRecruteur.findOneAndUpdate( - { email: user.email }, - { - $push: { - status: { - validation_type: VALIDATION_UTILISATEUR.AUTO, - status: ETAT_UTILISATEUR.DESACTIVE, - reason: `Utilisateur en doublon (traitement des majuscules ${new Date()}`, - user: "SERVEUR", - }, - }, - } - ) - const { establishment_id } = user - if (establishment_id) { - await Recruiter.findOneAndUpdate({ establishment_id }, { $set: { status: RECRUITER_STATUS.ARCHIVE } }) - } - return - } else { - user.email = user.email.toLowerCase() - await user.save() - } - }) - return stat -}) diff --git a/server/src/jobs/lba_recruteur/formulaire/misc/recoverGeocoordinates.ts b/server/src/jobs/lba_recruteur/formulaire/misc/recoverGeocoordinates.ts deleted file mode 100644 index 79fd82ddf7..0000000000 --- a/server/src/jobs/lba_recruteur/formulaire/misc/recoverGeocoordinates.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { ENTREPRISE } from "shared/constants/recruteur" - -import { logger } from "../../../../common/logger" -import { Recruiter, UserRecruteur } from "../../../../common/model" -import { asyncForEach, sleep } from "../../../../common/utils/asyncUtils" -import { GeoCoord, getGeoCoordinates } from "../../../../services/etablissement.service" - -const recoverMissingGeocoordinatesUserRecruteur = async () => { - const users = await UserRecruteur.find({ geo_coordinates: "NOT FOUND", type: ENTREPRISE }) - - await asyncForEach(users, async (user) => { - if (!user.address_detail) return - await sleep(500) - - let geocoord: GeoCoord | null - if ("l4" in user.address_detail) { - // if address data is in API address V2 - geocoord = await getGeoCoordinates(`${user.address_detail.l4} ${user.address_detail.l6}`) - logger.info(`${user.establishment_siret} - geocoord: ${geocoord} - adresse: ${user.address_detail.l4} ${user.address_detail.l6} `) - } else { - // else API address V3 - geocoord = await getGeoCoordinates(`${user.address_detail?.acheminement_postal?.l4} ${user.address_detail?.acheminement_postal?.l6}`) - logger.info(`${user.establishment_siret} - geocoord: ${geocoord} - adresse: ${user.address_detail?.acheminement_postal?.l4} ${user.address_detail?.acheminement_postal?.l6} `) - } - user.geo_coordinates = geocoord ? `${geocoord.latitude},${geocoord.longitude}` : null - await user.save() - }) -} - -const recoverMissingGeocoordinatesRecruiters = async () => { - const recruiters = await Recruiter.find({ geo_coordinates: "NOT FOUND" }) - - await asyncForEach(recruiters, async (recruiter) => { - if (!recruiter.address_detail) return - await sleep(500) - - let geocoord: GeoCoord | null - if (recruiter.address_detail.l4) { - // if address data is in API address V2 - geocoord = await getGeoCoordinates(`${recruiter.address_detail.l4} ${recruiter.address_detail.l6}`) - } else { - // else API address V3 - geocoord = await getGeoCoordinates(`${recruiter.address_detail.acheminement_postal.l4} ${recruiter.address_detail.acheminement_postal.l6}`) - } - logger.info( - `${recruiter.establishment_siret} - geocoord: ${geocoord} - adresse: ${recruiter.address_detail.acheminement_postal.l4} ${recruiter.address_detail.acheminement_postal.l6} ` - ) - recruiter.geo_coordinates = geocoord ? `${geocoord.latitude},${geocoord.longitude}` : null - await recruiter.save() - }) -} - -export const recoverMissingGeocoordinates = async () => { - await recoverMissingGeocoordinatesRecruiters() - await recoverMissingGeocoordinatesUserRecruteur() -} diff --git a/server/src/jobs/lba_recruteur/user/misc/fixUserRecruteurCfaDataValidation.ts b/server/src/jobs/lba_recruteur/user/misc/fixUserRecruteurCfaDataValidation.ts deleted file mode 100644 index 703e49177b..0000000000 --- a/server/src/jobs/lba_recruteur/user/misc/fixUserRecruteurCfaDataValidation.ts +++ /dev/null @@ -1,59 +0,0 @@ -import Boom from "boom" -import { ZCfaReferentielData } from "shared/models" - -import { logger } from "@/common/logger" -import { UserRecruteur } from "@/common/model" -import { asyncForEach } from "@/common/utils/asyncUtils" -import { sentryCaptureException } from "@/common/utils/sentryUtils" -import { notifyToSlack } from "@/common/utils/slackUtils" -import { getOrganismeDeFormationDataFromSiret } from "@/services/etablissement.service" -import { updateUser } from "@/services/userRecruteur.service" - -export const fixUserRecruiterCfaDataValidation = async () => { - const subject = "Fix data validations pour les userrecruteurs CFA : address_detail" - const userRecruteurs = await UserRecruteur.find({ type: "CFA" }).lean() - const stats = { success: 0, failure: 0, skip: 0 } - logger.info(`${subject}: ${userRecruteurs.length} user recruteurs à mettre à jour...`) - await asyncForEach(userRecruteurs, async (userRecruiter, index) => { - try { - index % 100 === 0 && logger.info("index", index) - const { establishment_siret, is_qualiopi, establishment_raison_sociale, address_detail, address, geo_coordinates } = userRecruiter - if ( - !ZCfaReferentielData.pick({ - is_qualiopi: true, - establishment_siret: true, - establishment_raison_sociale: true, - address_detail: true, - address: true, - geo_coordinates: true, - }).safeParse({ - is_qualiopi, - establishment_siret, - establishment_raison_sociale, - address_detail, - address, - geo_coordinates, - }).success - ) { - if (!establishment_siret) { - throw Boom.internal("Missing establishment_siret", { _id: userRecruiter._id }) - } - const cfaData = await getOrganismeDeFormationDataFromSiret(establishment_siret, false) - await updateUser({ _id: userRecruiter._id }, cfaData) - stats.success++ - } else { - stats.skip++ - } - } catch (err) { - logger.error(err) - sentryCaptureException(err) - stats.failure++ - } - }) - await notifyToSlack({ - subject, - message: `${stats.failure} erreurs. ${stats.success} mises à jour. ${stats.skip} ignorés.`, - error: stats.failure > 0, - }) - return stats -} diff --git a/server/src/security/authorisationService.ts b/server/src/security/authorisationService.ts index fe47236d65..2148fd866b 100644 --- a/server/src/security/authorisationService.ts +++ b/server/src/security/authorisationService.ts @@ -7,6 +7,7 @@ import { assertUnreachable } from "shared/utils" import { Primitive } from "type-fest" import { Application, Recruiter, UserRecruteur } from "@/common/model" +import { getUserRecruteurById } from "@/services/userRecruteur.service" import { controlUserState } from "../services/login.service" @@ -107,7 +108,7 @@ async function getUserResource(schema: S, req: IRe await Promise.all( schema.securityScheme.resources.user.map(async (userDef) => { if ("_id" in userDef) { - const userOpt = await UserRecruteur.findById(getAccessResourcePathValue(userDef._id, req)).lean() + const userOpt = await getUserRecruteurById(getAccessResourcePathValue(userDef._id, req)) return userOpt ? [userOpt] : [] } if ("opco" in userDef) { diff --git a/server/src/services/etablissement.service.ts b/server/src/services/etablissement.service.ts index 585b238430..d7f0a423a9 100644 --- a/server/src/services/etablissement.service.ts +++ b/server/src/services/etablissement.service.ts @@ -35,7 +35,7 @@ import { import { createFormulaire, getFormulaire } from "./formulaire.service" import mailer, { sanitizeForEmail } from "./mailer.service" import { getOpcoBySirenFromDB, saveOpco } from "./opco.service" -import { autoValidateUser, createUser, getUserRecruteurByEmail, getUserStatus, setUserHasToBeManuallyValidated, setUserInError } from "./userRecruteur.service" +import { autoValidateUser, createUser, getUser, getUserRecruteurByEmail, getUserStatus, setUserHasToBeManuallyValidated, setUserInError } from "./userRecruteur.service" const apiParams = { token: config.entreprise.apiKey, @@ -181,13 +181,6 @@ export const findByIdAndUpdate = async (id, values): Promise => Etablissement.findByIdAndDelete(id).lean() -/** - * @description Get etablissement from a given query - * @param {Object} query - * @returns {Promise} - */ -export const getEtablissement = async (query: FilterQuery): Promise => UserRecruteur.findOne(query).lean() - /** * @description Get opco details from CFADOCK API for a given SIRET * @param {String} siret @@ -692,7 +685,7 @@ export const getEntrepriseDataFromSiret = async ({ siret, cfa_delegated_siret }: export const getOrganismeDeFormationDataFromSiret = async (siret: string, shouldValidate = true) => { if (shouldValidate) { - const cfaUserRecruteurOpt = await getEtablissement({ establishment_siret: siret, type: CFA }) + const cfaUserRecruteurOpt = await getUser({ establishment_siret: siret, type: CFA }) if (cfaUserRecruteurOpt) { throw Boom.forbidden("Ce numéro siret est déjà associé à un compte utilisateur.", { reason: BusinessErrorCodes.ALREADY_EXISTS }) } diff --git a/server/src/services/formulaire.service.ts b/server/src/services/formulaire.service.ts index ca7a6e906d..3776ab6f31 100644 --- a/server/src/services/formulaire.service.ts +++ b/server/src/services/formulaire.service.ts @@ -15,7 +15,7 @@ import config from "../config" import { createCfaUnsubscribeToken, createViewDelegationLink } from "./appLinks.service" import { getCatalogueEtablissements, getCatalogueFormations } from "./catalogue.service" import dayjs from "./dayjs.service" -import { getEtablissement, sendEmailConfirmationEntreprise } from "./etablissement.service" +import { sendEmailConfirmationEntreprise } from "./etablissement.service" import mailer, { sanitizeForEmail } from "./mailer.service" import { getRomeDetailsFromDB } from "./rome.service" import { getUser, getUserStatus } from "./userRecruteur.service" @@ -44,7 +44,7 @@ export const getOffreAvecInfoMandataire = async (id: string | ObjectIdType): Pro if (recruiterOpt.is_delegated && recruiterOpt.address) { const { cfa_delegated_siret } = recruiterOpt if (cfa_delegated_siret) { - const cfa = await getEtablissement({ establishment_siret: cfa_delegated_siret }) + const cfa = await getUser({ establishment_siret: cfa_delegated_siret }) if (cfa) { recruiterOpt.phone = cfa.phone diff --git a/server/src/services/lbajob.service.ts b/server/src/services/lbajob.service.ts index 194c2a9a10..d052c75997 100644 --- a/server/src/services/lbajob.service.ts +++ b/server/src/services/lbajob.service.ts @@ -11,10 +11,10 @@ import { sentryCaptureException } from "../common/utils/sentryUtils" import { IApplicationCount, getApplicationByJobCount } from "./application.service" import { NIVEAUX_POUR_LBA } from "./constant.service" -import { getEtablissement } from "./etablissement.service" import { getOffreAvecInfoMandataire, incrementLbaJobViewCount } from "./formulaire.service" import { ILbaItemLbaJob } from "./lbaitem.shared.service.types" import { filterJobsByOpco } from "./opco.service" +import { getUser } from "./userRecruteur.service" const JOB_SEARCH_LIMIT = 250 @@ -70,7 +70,7 @@ export const getJobs = async ({ distance, lat, lon, romes, niveau }: { distance: const jobs: any[] = [] if (x.is_delegated) { - const cfa = await getEtablissement({ establishment_siret: x.cfa_delegated_siret }) + const cfa = await getUser({ establishment_siret: x.cfa_delegated_siret }) x.phone = cfa?.phone x.email = cfa?.email || "" diff --git a/server/src/services/user.service.ts b/server/src/services/user.service.ts index f544fd08ff..23be45c455 100644 --- a/server/src/services/user.service.ts +++ b/server/src/services/user.service.ts @@ -3,7 +3,7 @@ import { IUser, IUserRecruteur } from "shared" import { ETAT_UTILISATEUR } from "shared/constants/recruteur" import { IUserForOpco } from "shared/routes/user.routes" -import { Recruiter, User, UserRecruteur } from "../common/model/index" +import { Recruiter, User, User2, UserRecruteur } from "../common/model/index" /** * @description Returns user from its email. @@ -144,7 +144,7 @@ const getValidatorIdentityFromStatus = async (status: IUserRecruteur["status"]) return await Promise.all( status.map(async (state) => { if (state.user === "SERVEUR") return state - const user = await UserRecruteur.findById(state.user).select({ first_name: 1, last_name: 1, _id: 0 }).lean() + const user = await User2.findById(state.user).select({ first_name: 1, last_name: 1, _id: 0 }).lean() return { ...state, user: `${user?.first_name} ${user?.last_name}` } }) ) diff --git a/server/src/services/userRecruteur.service.ts b/server/src/services/userRecruteur.service.ts index 80806032b8..b287cf3b46 100644 --- a/server/src/services/userRecruteur.service.ts +++ b/server/src/services/userRecruteur.service.ts @@ -1,7 +1,8 @@ import { randomUUID } from "crypto" import Boom from "boom" -import type { FilterQuery, ModelUpdateOptions, ObjectId, UpdateQuery } from "mongoose" +import { ObjectId } from "mongodb" +import type { FilterQuery, ModelUpdateOptions, UpdateQuery } from "mongoose" import { IUserRecruteur, IUserRecruteurWritable, IUserStatusValidation, UserRecruteurForAdminProjection, assertUnreachable } from "shared" import { CFA, ENTREPRISE, ETAT_UTILISATEUR, VALIDATION_UTILISATEUR } from "shared/constants/recruteur" import { EntrepriseStatus, IEntrepriseStatusEvent } from "shared/models/entreprise.model" @@ -100,7 +101,7 @@ const roleStatusToUserRecruteurStatus = (roleStatus: AccessStatus): ETAT_UTILISA } } -export const getUserRecruteurById = (id: string | ObjectId) => getUserRecruteurByUser2Query({ _id: id }) +export const getUserRecruteurById = (id: string | ObjectId) => getUserRecruteurByUser2Query({ _id: typeof id === "string" ? new ObjectId(id) : id }) export const getUserRecruteurByEmail = (email: string) => getUserRecruteurByUser2Query({ email }) const getUserRecruteurByUser2Query = async (user2query: Partial): Promise => { @@ -202,7 +203,7 @@ export const updateUser = async ( * @returns {Promise} */ export const removeUser = async (id: IUserRecruteur["_id"] | string) => { - const user = await UserRecruteur.findById(id) + const user = await getUserRecruteurById(id) if (!user) { throw new Error(`Unable to find user ${id}`) } From 1d670c6eac27b8a311ecbe345e809e2b71cf8d9e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9my=20Auricoste?= Date: Mon, 26 Feb 2024 18:15:33 +0100 Subject: [PATCH 12/78] fix: refactor updateLastConnectionDate --- .../http/controllers/etablissementRecruteur.controller.ts | 3 ++- server/src/http/controllers/login.controller.ts | 4 ++-- server/src/services/userRecruteur.service.ts | 5 +++-- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/server/src/http/controllers/etablissementRecruteur.controller.ts b/server/src/http/controllers/etablissementRecruteur.controller.ts index edb15cbf9c..51e6b2ed60 100644 --- a/server/src/http/controllers/etablissementRecruteur.controller.ts +++ b/server/src/http/controllers/etablissementRecruteur.controller.ts @@ -281,7 +281,8 @@ export default (server: Server) => { await sendWelcomeEmailToUserRecruteur(userRecruteur) } - const connectedUser = await updateLastConnectionDate(userRecruteur.email) + await updateLastConnectionDate(userRecruteur.email) + const connectedUser = await getUserRecruteurByEmail(userRecruteur.email) if (!connectedUser) { throw Boom.forbidden() diff --git a/server/src/http/controllers/login.controller.ts b/server/src/http/controllers/login.controller.ts index 759652c430..5f047c40bf 100644 --- a/server/src/http/controllers/login.controller.ts +++ b/server/src/http/controllers/login.controller.ts @@ -105,8 +105,8 @@ export default (server: Server) => { throw Boom.forbidden() } - const connectedUser = await updateLastConnectionDate(formatedEmail) - + await updateLastConnectionDate(formatedEmail) + const connectedUser = await getUserRecruteurByEmail(formatedEmail) if (!connectedUser) { throw Boom.forbidden() } diff --git a/server/src/services/userRecruteur.service.ts b/server/src/services/userRecruteur.service.ts index b287cf3b46..3603b26d30 100644 --- a/server/src/services/userRecruteur.service.ts +++ b/server/src/services/userRecruteur.service.ts @@ -216,8 +216,9 @@ export const removeUser = async (id: IUserRecruteur["_id"] | string) => { * @param {IUserRecruteur["email"]} email * @returns {Promise} */ -export const updateLastConnectionDate = (email: IUserRecruteur["email"]) => - UserRecruteur.findOneAndUpdate({ email: email.toLowerCase() }, { last_connection: new Date() }, { new: true }).lean() +export const updateLastConnectionDate = async (email: IUserRecruteur["email"]): Promise => { + await User2.findOneAndUpdate({ email: email.toLowerCase() }, { last_action_date: new Date() }, { new: true }).lean() +} /** * @description update user validation status From 429f8e4bd4f1fc6082c3b8c9dfdbae564b1c3074 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9my=20Auricoste?= Date: Mon, 26 Feb 2024 18:25:40 +0100 Subject: [PATCH 13/78] fix: clean vieux jobs --- server/src/commands.ts | 6 -- server/src/jobs/jobs.ts | 3 - .../formulaire/misc/addEnseigne.ts | 47 ------------ ...eAddressDetailOnUserrecrutersCollection.ts | 72 ------------------ .../optout/sendMailToEtablissements.ts | 74 ------------------ .../user/misc/fillRecruiterRaisonSociale.ts | 76 ------------------- 6 files changed, 278 deletions(-) delete mode 100644 server/src/jobs/lba_recruteur/formulaire/misc/addEnseigne.ts delete mode 100644 server/src/jobs/lba_recruteur/formulaire/misc/updateAddressDetailOnUserrecrutersCollection.ts delete mode 100644 server/src/jobs/lba_recruteur/optout/sendMailToEtablissements.ts delete mode 100644 server/src/jobs/lba_recruteur/user/misc/fillRecruiterRaisonSociale.ts diff --git a/server/src/commands.ts b/server/src/commands.ts index c82319466b..554b0ca6d5 100644 --- a/server/src/commands.ts +++ b/server/src/commands.ts @@ -538,12 +538,6 @@ program .option("-q, --queued", "Run job asynchronously", false) .action(createJobAction("referentiel:rncp-romes:update")) -program - .command("fill-recruiters-raison-sociale") - .description("Remplissage des raisons sociales pour les recruiters et userRecruiters qui n'en ont pas") - .option("-q, --queued", "Run job asynchronously", false) - .action(createJobAction("recruiters:raison-sociale:fill")) - program .command("fix-job-expiration-date") .description("Répare les date d'expiration d'offre qui seraient trop dans le futur") diff --git a/server/src/jobs/jobs.ts b/server/src/jobs/jobs.ts index a3715a3a5b..60a9819190 100644 --- a/server/src/jobs/jobs.ts +++ b/server/src/jobs/jobs.ts @@ -39,7 +39,6 @@ import { relanceFormulaire } from "./lba_recruteur/formulaire/relanceFormulaire" import { importReferentielOpcoFromConstructys } from "./lba_recruteur/opco/constructys/constructysImporter" import { relanceOpco } from "./lba_recruteur/opco/relanceOpco" import { createOffreCollection } from "./lba_recruteur/seed/createOffre" -import { fillRecruiterRaisonSociale } from "./lba_recruteur/user/misc/fillRecruiterRaisonSociale" import { fixUserRecruiterDataValidation } from "./lba_recruteur/user/misc/fixUserRecruteurDataValidation" import { checkAwaitingCompaniesValidation } from "./lba_recruteur/user/misc/updateMissingActivationState" import { updateSiretInfosInError } from "./lba_recruteur/user/misc/updateSiretInfosInError" @@ -357,8 +356,6 @@ export async function runJob(job: IInternalJobsCronTask | IInternalJobsSimple): } case "diplomes-metiers:update": return updateDiplomesMetiers() - case "recruiters:raison-sociale:fill": - return fillRecruiterRaisonSociale() case "recruiters:expiration-date:fix": return fixJobExpirationDate() case "recruiters:job-type:fix": diff --git a/server/src/jobs/lba_recruteur/formulaire/misc/addEnseigne.ts b/server/src/jobs/lba_recruteur/formulaire/misc/addEnseigne.ts deleted file mode 100644 index b9c5967dc4..0000000000 --- a/server/src/jobs/lba_recruteur/formulaire/misc/addEnseigne.ts +++ /dev/null @@ -1,47 +0,0 @@ -import Boom from "boom" - -import { Recruiter, UserRecruteur } from "../../../../common/model/index" -import { getEtablissementFromGouv } from "../../../../services/etablissement.service" -import { runScript } from "../../../scriptWrapper" - -runScript(async () => { - const errors: any[] = [] - const itemsUpdated = {} - const [formulaires, users] = await Promise.all([Recruiter.find({ siret: { $exists: true } }).lean(), UserRecruteur.find({ siret: { $exists: true } }).lean()]) - - for (const formulaire of formulaires) { - try { - const data = await getEtablissementFromGouv(formulaire.establishment_siret) - const enseigneFromApiEntreprise = data?.data.enseigne - if (enseigneFromApiEntreprise) { - await Recruiter.findOneAndUpdate({ _id: formulaire._id }, { establishment_enseigne: enseigneFromApiEntreprise }) - itemsUpdated[`${formulaire.establishment_siret}`] = enseigneFromApiEntreprise - } - } catch (error: any) { - errors.push(error) - } - } - - for (const user of users) { - try { - if (!user.establishment_siret) { - throw Boom.internal("unexpected: no establishment_siret on userRecruteur", { userId: user._id }) - } - const data = await getEtablissementFromGouv(user.establishment_siret) - - const enseigneFromApiEntreprise = data?.data.enseigne - - if (enseigneFromApiEntreprise) { - await UserRecruteur.findOneAndUpdate({ _id: user._id }, { establishment_enseigne: enseigneFromApiEntreprise }) - itemsUpdated[`${user.establishment_siret}`] = enseigneFromApiEntreprise - } - } catch (error: any) { - errors.push(error) - } - } - - return { - errors, - itemsUpdated, - } -}) diff --git a/server/src/jobs/lba_recruteur/formulaire/misc/updateAddressDetailOnUserrecrutersCollection.ts b/server/src/jobs/lba_recruteur/formulaire/misc/updateAddressDetailOnUserrecrutersCollection.ts deleted file mode 100644 index c255b49a70..0000000000 --- a/server/src/jobs/lba_recruteur/formulaire/misc/updateAddressDetailOnUserrecrutersCollection.ts +++ /dev/null @@ -1,72 +0,0 @@ -import Boom from "boom" - -import { logger } from "../../../../common/logger" -import { Recruiter, UserRecruteur } from "../../../../common/model/index" -import { asyncForEach, delay } from "../../../../common/utils/asyncUtils" -import { CFA, ENTREPRISE } from "../../../../services/constant.service" -import { getEtablissementFromGouv } from "../../../../services/etablissement.service" - -export const updateAddressDetailOnUserrecrutersCollection = async () => { - logger.info("Start update user adresse detail") - const users = await UserRecruteur.find({ type: { $in: [ENTREPRISE, CFA] }, address_detail: null }) - - logger.info(`${users.length} entries to update...`) - - if (!users.length) return - - await asyncForEach(users, async (user, index) => { - console.log(`${index}/${users.length} - ${user.type} - ${user.establishment_siret} - ${user._id}`) - - try { - await delay(500) - const { establishment_siret } = user - if (!establishment_siret) { - throw Boom.internal("unexpected: no establishment_siret on userRecruteur", { userId: user._id }) - } - const etablissement = await getEtablissementFromGouv(establishment_siret) - - if (!etablissement) return - - user.address_detail = etablissement.data.adresse - - if (user.type !== ENTREPRISE) { - await user.save() - return - } - - const { establishment_id } = user - if (!establishment_id) { - throw Boom.internal("unexpected: no establishment_id on userRecruteur of type ENTREPRISE", { userId: user._id }) - } - const formulaire = await Recruiter.findOne({ establishment_id }) - - if (!formulaire) { - return - } - - formulaire.address_detail = formulaire ? etablissement.data.adresse : undefined - - await Promise.all([user.save(), formulaire.save()]) - } catch (error: any) { - const { errors } = error.response.data - - if (errors.length) { - if ( - errors.includes("Le numéro de siret n'est pas correctement formatté") || - errors.includes("Le siret ou siren indiqué n'existe pas, n'est pas connu ou ne comporte aucune information pour cet appel") - ) { - console.log(`Invalid siret DELETED : ${user.establishment_siret} - User & Formulaire removed`) - const { establishment_id } = user - await UserRecruteur.findByIdAndDelete(user._id) - if (establishment_id) { - await Recruiter.findOneAndRemove({ establishment_id }) - } - return - } - } else { - console.log(error.response) - } - } - }) - logger.info("End update user adresse detail") -} diff --git a/server/src/jobs/lba_recruteur/optout/sendMailToEtablissements.ts b/server/src/jobs/lba_recruteur/optout/sendMailToEtablissements.ts deleted file mode 100644 index be1bade54f..0000000000 --- a/server/src/jobs/lba_recruteur/optout/sendMailToEtablissements.ts +++ /dev/null @@ -1,74 +0,0 @@ -import Joi from "joi" -import { differenceBy } from "lodash-es" - -import { getStaticFilePath } from "@/common/utils/getStaticFilePath" -import { createOptoutValidateMagicLink } from "@/services/appLinks.service" - -import { logger } from "../../../common/logger" -import { Optout, UserRecruteur } from "../../../common/model/index" -import { asyncForEach } from "../../../common/utils/asyncUtils" -import config from "../../../config" -import mailer from "../../../services/mailer.service" -import { runScript } from "../../scriptWrapper" - -/** - * @param {number} ms delay in millisecond - */ -const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) - -runScript(async () => { - const [optOutList, users] = await Promise.all([Optout.find().lean(), UserRecruteur.find({ type: "CFA" }).lean()]) - - const etablissementsToContact = differenceBy(optOutList, users, "siret") - - logger.info(`Sending optout mail to ${etablissementsToContact.length} etablissement`) - - await asyncForEach(etablissementsToContact, async (etablissement) => { - // Filter contact that have already recieved an invitation from the contacts array - const contact = etablissement.contacts.filter((contact) => { - const found = etablissement.mail.find((y) => y.email === contact.email) - if (!found) { - return contact - } - }) - - if (!contact.length) { - logger.info(`Tous les contacts ont été solicité pour cet établissement : ${etablissement.siret}`) - return - } - - const { error, value: email } = Joi.string().email().validate(contact[0].email, { abortEarly: false }) - - if (error) { - await Optout.findByIdAndUpdate(etablissement._id, { $push: { mail: { email, messageId: "INVALIDE_EMAIL" } } }) - return - } - - logger.info(`---- Sending mail for ${etablissement.siret} — ${email} ----`) - - let data - - try { - data = await mailer.sendEmail({ - to: email, - subject: "Vous êtes invité à rejoindre La bonne alternance", - template: getStaticFilePath("./templates/mail-optout.mjml.ejs"), - data: { - images: { - logoLba: `${config.publicUrl}/images/emails/logo_LBA.png?raw=true`, - }, - raison_sociale: etablissement.raison_sociale, - url: createOptoutValidateMagicLink(email, etablissement.siret), - }, - }) - } catch (errror) { - console.log(`ERROR : ${email} - ${etablissement.siret}`, "-----", error) - return - } - - await Optout.findByIdAndUpdate(etablissement._id, { $push: { mail: { email, messageId: data.messageId } } }) - logger.info(`${JSON.stringify(data)} — ${etablissement.siret} — ${email}`) - - await sleep(500) - }) -}) diff --git a/server/src/jobs/lba_recruteur/user/misc/fillRecruiterRaisonSociale.ts b/server/src/jobs/lba_recruteur/user/misc/fillRecruiterRaisonSociale.ts deleted file mode 100644 index 6b1b2b8f5d..0000000000 --- a/server/src/jobs/lba_recruteur/user/misc/fillRecruiterRaisonSociale.ts +++ /dev/null @@ -1,76 +0,0 @@ -import Boom from "boom" - -import { logger } from "../../../../common/logger" -import { Recruiter, UserRecruteur } from "../../../../common/model/index" -import { asyncForEach } from "../../../../common/utils/asyncUtils" -import { sentryCaptureException } from "../../../../common/utils/sentryUtils" -import { notifyToSlack } from "../../../../common/utils/slackUtils" -import { formatEntrepriseData, getEtablissementFromGouv } from "../../../../services/etablissement.service" -import { updateFormulaire } from "../../../../services/formulaire.service" -import { updateUser } from "../../../../services/userRecruteur.service" - -const fillRecruiters = async () => { - const recruiters = await Recruiter.find({ - establishment_raison_sociale: null, - }).lean() - const stats = { success: 0, failure: 0 } - logger.info(`Remplissage des raisons sociales vides: ${recruiters.length} recruteurs à mettre à jour...`) - await asyncForEach(recruiters, async (recruiter) => { - const { establishment_siret, establishment_id } = recruiter - try { - const siretResponse = await getEtablissementFromGouv(establishment_siret) - if (!siretResponse) { - throw Boom.internal("Pas de réponse") - } - const { establishment_raison_sociale } = formatEntrepriseData(siretResponse.data) - await updateFormulaire(establishment_id, { establishment_raison_sociale }) - stats.success++ - } catch (err) { - sentryCaptureException(err) - stats.failure++ - } - }) - await notifyToSlack({ - subject: "Remplissage des raisons sociales - recruiters", - message: `${stats.success} succès. ${stats.failure} erreurs.`, - error: stats.failure > 0, - }) - return stats -} - -const fillUserRecruiters = async () => { - const userRecruiters = await UserRecruteur.find({ - establishment_raison_sociale: null, - }).lean() - const stats = { success: 0, failure: 0 } - logger.info(`Remplissage des raisons sociales vides: ${userRecruiters.length} user recruteurs à mettre à jour...`) - await asyncForEach(userRecruiters, async (userRecruiter) => { - const { establishment_siret } = userRecruiter - try { - if (!establishment_siret) { - throw Boom.internal("Missing establishment_siret", { _id: userRecruiter._id }) - } - const siretResponse = await getEtablissementFromGouv(establishment_siret) - if (!siretResponse) { - throw Boom.internal("Pas de réponse") - } - const { establishment_raison_sociale } = formatEntrepriseData(siretResponse.data) - await updateUser({ _id: userRecruiter._id }, { establishment_raison_sociale }) - stats.success++ - } catch (err) { - sentryCaptureException(err) - stats.failure++ - } - }) - await notifyToSlack({ - subject: "Remplissage des raisons sociales - user recruiters", - message: `${stats.success} succès. ${stats.failure} erreurs.`, - error: stats.failure > 0, - }) - return stats -} - -export const fillRecruiterRaisonSociale = async () => { - await fillUserRecruiters() - await fillRecruiters() -} From 68575669325407d81fec47b493377b18e05416a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9my=20Auricoste?= Date: Tue, 27 Feb 2024 09:54:28 +0100 Subject: [PATCH 14/78] fix: refactor to validateUserEmail and updateUser2Fields --- server/src/commands.ts | 6 -- .../etablissementRecruteur.controller.ts | 4 +- .../src/http/controllers/user.controller.ts | 7 ++- server/src/jobs/jobs.ts | 3 - .../misc/fixUserRecruteurDataValidation.ts | 62 ------------------- .../user/misc/updateMissingActivationState.ts | 3 +- server/src/services/userRecruteur.service.ts | 17 ++++- 7 files changed, 24 insertions(+), 78 deletions(-) delete mode 100644 server/src/jobs/lba_recruteur/user/misc/fixUserRecruteurDataValidation.ts diff --git a/server/src/commands.ts b/server/src/commands.ts index 554b0ca6d5..3d6ba6d8dc 100644 --- a/server/src/commands.ts +++ b/server/src/commands.ts @@ -562,12 +562,6 @@ program .option("-q, --queued", "Run job asynchronously", false) .action(createJobAction("recruiters:data-validation:fix")) -program - .command("fix-data-validation-user-recruteurs") - .description("Répare les data de la collection userrecruteurs") - .option("-q, --queued", "Run job asynchronously", false) - .action(createJobAction("user-recruters:data-validation:fix")) - program .command("anonymize-user-recruteurs") .description("Anonymize les userrecruteurs qui ne se sont pas connectés depuis plus de 2 ans") diff --git a/server/src/http/controllers/etablissementRecruteur.controller.ts b/server/src/http/controllers/etablissementRecruteur.controller.ts index 51e6b2ed60..a3f514b534 100644 --- a/server/src/http/controllers/etablissementRecruteur.controller.ts +++ b/server/src/http/controllers/etablissementRecruteur.controller.ts @@ -31,7 +31,7 @@ import { sendWelcomeEmailToUserRecruteur, setUserHasToBeManuallyValidated, updateLastConnectionDate, - updateUser, + updateUser2Fields, } from "../../services/userRecruteur.service" import { Server } from "../server" @@ -254,7 +254,7 @@ export default (server: Server) => { if (exists) { throw Boom.badRequest("L'adresse mail est déjà associée à un compte La bonne alternance.") } - await updateUser({ _id: req.params.id }, rest) + await updateUser2Fields(req.params.id, rest) return res.status(200).send({ ok: true }) } ) diff --git a/server/src/http/controllers/user.controller.ts b/server/src/http/controllers/user.controller.ts index da9ea11084..9758be2f0c 100644 --- a/server/src/http/controllers/user.controller.ts +++ b/server/src/http/controllers/user.controller.ts @@ -23,7 +23,9 @@ import { removeUser, sendWelcomeEmailToUserRecruteur, updateUser, + updateUser2Fields, updateUserValidationHistory, + validateUserEmail, } from "../../services/userRecruteur.service" import { Server } from "../server" @@ -248,7 +250,8 @@ export default (server: Server) => { const update = { email: formattedEmail, ...userPayload } - const user = await updateUser({ _id: userId }, update) + await updateUser2Fields(userId, update) + const user = await getUserRecruteurById(userId) return res.status(200).send(user) } ) @@ -322,7 +325,7 @@ export default (server: Server) => { } // validate user email addresse - await updateUser({ _id: user._id }, { is_email_checked: true }) + await validateUserEmail(user._id) await sendWelcomeEmailToUserRecruteur(user) return res.status(200).send(user) } diff --git a/server/src/jobs/jobs.ts b/server/src/jobs/jobs.ts index 60a9819190..c79fee2264 100644 --- a/server/src/jobs/jobs.ts +++ b/server/src/jobs/jobs.ts @@ -39,7 +39,6 @@ import { relanceFormulaire } from "./lba_recruteur/formulaire/relanceFormulaire" import { importReferentielOpcoFromConstructys } from "./lba_recruteur/opco/constructys/constructysImporter" import { relanceOpco } from "./lba_recruteur/opco/relanceOpco" import { createOffreCollection } from "./lba_recruteur/seed/createOffre" -import { fixUserRecruiterDataValidation } from "./lba_recruteur/user/misc/fixUserRecruteurDataValidation" import { checkAwaitingCompaniesValidation } from "./lba_recruteur/user/misc/updateMissingActivationState" import { updateSiretInfosInError } from "./lba_recruteur/user/misc/updateSiretInfosInError" import buildSAVE from "./lbb/buildSAVE" @@ -364,8 +363,6 @@ export async function runJob(job: IInternalJobsCronTask | IInternalJobsSimple): return fixApplications() case "recruiters:data-validation:fix": return fixRecruiterDataValidation() - case "user-recruters:data-validation:fix": - return fixUserRecruiterDataValidation() case "referentiel-opco:constructys:import": { const { parallelism } = job.payload return importReferentielOpcoFromConstructys(parseInt(parallelism)) diff --git a/server/src/jobs/lba_recruteur/user/misc/fixUserRecruteurDataValidation.ts b/server/src/jobs/lba_recruteur/user/misc/fixUserRecruteurDataValidation.ts deleted file mode 100644 index 82375ea9d0..0000000000 --- a/server/src/jobs/lba_recruteur/user/misc/fixUserRecruteurDataValidation.ts +++ /dev/null @@ -1,62 +0,0 @@ -import Boom from "boom" -import { ZGlobalAddress } from "shared/models" - -import { logger } from "@/common/logger" -import { UserRecruteur } from "@/common/model" -import { asyncForEach } from "@/common/utils/asyncUtils" -import { sentryCaptureException } from "@/common/utils/sentryUtils" -import { notifyToSlack } from "@/common/utils/slackUtils" -import { formatEntrepriseData, getEtablissementFromGouv, getGeoCoordinates } from "@/services/etablissement.service" -import { updateUser } from "@/services/userRecruteur.service" - -const fixAddressDetailAcademie = async () => { - const subject = "Fix data validations pour userrecruteurs : address_detail.academie & address_detail.l1" - const userRecruteurs = await UserRecruteur.find({ - $or: [ - { - "address_detail.academie": { $exists: true }, - }, - { - "address_detail.l1": { $exists: true }, - }, - ], - type: "ENTREPRISE", - }).lean() - const stats = { success: 0, failure: 0 } - logger.info(`${subject}: ${userRecruteurs.length} user recruteurs à mettre à jour...`) - await asyncForEach(userRecruteurs, async (userRecruiter, index) => { - try { - index % 100 === 0 && logger.info("index", index) - const { address_detail, establishment_siret } = userRecruiter - if (address_detail && ("academie" in address_detail || "l1" in address_detail) && !ZGlobalAddress.safeParse(address_detail).success) { - if (!establishment_siret) { - throw Boom.internal("Missing establishment_siret", { _id: userRecruiter._id }) - } - const siretResponse = await getEtablissementFromGouv(establishment_siret) - if (!siretResponse) { - throw Boom.internal("Pas de réponse") - } - const entrepriseData = formatEntrepriseData(siretResponse.data) - const numeroEtRue = entrepriseData.address_detail.acheminement_postal.l4 - const codePostalEtVille = entrepriseData.address_detail.acheminement_postal.l6 - const { latitude, longitude } = await getGeoCoordinates(`${numeroEtRue}, ${codePostalEtVille}`).catch(() => getGeoCoordinates(codePostalEtVille)) - const savedData = { ...entrepriseData, geo_coordinates: `${latitude},${longitude}` } - await updateUser({ _id: userRecruiter._id }, savedData) - } - stats.success++ - } catch (err) { - sentryCaptureException(err) - stats.failure++ - } - }) - await notifyToSlack({ - subject, - message: `${stats.failure} erreurs. ${stats.success} mises à jour`, - error: stats.failure > 0, - }) - return stats -} - -export const fixUserRecruiterDataValidation = async () => { - await fixAddressDetailAcademie() -} diff --git a/server/src/jobs/lba_recruteur/user/misc/updateMissingActivationState.ts b/server/src/jobs/lba_recruteur/user/misc/updateMissingActivationState.ts index 13ac04db6b..4a369ee067 100644 --- a/server/src/jobs/lba_recruteur/user/misc/updateMissingActivationState.ts +++ b/server/src/jobs/lba_recruteur/user/misc/updateMissingActivationState.ts @@ -8,7 +8,7 @@ import { notifyToSlack } from "../../../../common/utils/slackUtils" import { ENTREPRISE } from "../../../../services/constant.service" import { autoValidateCompany } from "../../../../services/etablissement.service" import { activateEntrepriseRecruiterForTheFirstTime, getFormulaire } from "../../../../services/formulaire.service" -import { sendWelcomeEmailToUserRecruteur, updateUser } from "../../../../services/userRecruteur.service" +import { sendWelcomeEmailToUserRecruteur } from "../../../../services/userRecruteur.service" export const checkAwaitingCompaniesValidation = async () => { logger.info(`Start update missing validation state for companies...`) @@ -52,7 +52,6 @@ export const checkAwaitingCompaniesValidation = async () => { await activateEntrepriseRecruiterForTheFirstTime(userFormulaire) // Validate user email addresse - await updateUser({ _id: entreprise._id }, { is_email_checked: true }) await sendWelcomeEmailToUserRecruteur(entreprise) } }) diff --git a/server/src/services/userRecruteur.service.ts b/server/src/services/userRecruteur.service.ts index 3603b26d30..7440637f01 100644 --- a/server/src/services/userRecruteur.service.ts +++ b/server/src/services/userRecruteur.service.ts @@ -7,7 +7,7 @@ import { IUserRecruteur, IUserRecruteurWritable, IUserStatusValidation, UserRecr import { CFA, ENTREPRISE, ETAT_UTILISATEUR, VALIDATION_UTILISATEUR } from "shared/constants/recruteur" import { EntrepriseStatus, IEntrepriseStatusEvent } from "shared/models/entreprise.model" import { AccessEntityType, AccessStatus, IRoleManagement } from "shared/models/roleManagement.model" -import { IUser2, UserEventType } from "shared/models/user2.model" +import { IUser2, IUserStatusEvent, UserEventType } from "shared/models/user2.model" import { getLastStatusEvent } from "shared/utils/getLastStatusEvent" import { entriesToTypedRecord, typedKeys } from "shared/utils/objectUtils" @@ -197,6 +197,21 @@ export const updateUser = async ( return userRecruterOpt } +export const updateUser2Fields = (userId: ObjectId, fields: Partial) => { + return User2.findOneAndUpdate({ _id: userId }, fields, { new: true }) +} + +export const validateUserEmail = async (userId: ObjectId) => { + const event: IUserStatusEvent = { + date: new Date(), + status: UserEventType.VALIDATION_EMAIL, + validation_type: VALIDATION_UTILISATEUR.MANUAL, + granted_by: userId.toString(), + reason: "user validated its email", + } + await User2.updateOne({ _id: userId }, { $push: { status: event } }) +} + /** * @description delete user from collection * @param {IUserRecruteur["_id"]} id From 6600ac6fb2c0b9e186977868cb0200f7dbde7bc2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9my=20Auricoste?= Date: Tue, 27 Feb 2024 10:00:53 +0100 Subject: [PATCH 15/78] fix: refactor export PE --- server/src/jobs/lba_recruteur/formulaire/misc/exportPE.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/server/src/jobs/lba_recruteur/formulaire/misc/exportPE.ts b/server/src/jobs/lba_recruteur/formulaire/misc/exportPE.ts index b711b3b193..304caa8f3f 100644 --- a/server/src/jobs/lba_recruteur/formulaire/misc/exportPE.ts +++ b/server/src/jobs/lba_recruteur/formulaire/misc/exportPE.ts @@ -2,7 +2,6 @@ import { createWriteStream } from "fs" import path from "path" import { Readable } from "stream" -import { pick } from "lodash-es" import { oleoduc, transformData, transformIntoCSV } from "oleoduc" import { RECRUITER_STATUS } from "shared/constants/recruteur" import { JOB_STATUS } from "shared/models" @@ -11,7 +10,7 @@ import { db } from "@/common/mongodb" import { sendCsvToPE } from "../../../../common/apis/Pe" import { logger } from "../../../../common/logger" -import { UserRecruteur } from "../../../../common/model/index" +import { Cfa } from "../../../../common/model/index" import { getDepartmentByZipCode } from "../../../../common/territoires" import { asyncForEach } from "../../../../common/utils/asyncUtils" import { notifyToSlack } from "../../../../common/utils/slackUtils" @@ -201,12 +200,13 @@ export const exportPE = async (): Promise => { logger.info(`get info from ${offres.length} offers...`) await asyncForEach(offres, async (offre) => { - const user = offre.is_delegated ? await UserRecruteur.findOne({ establishment_siret: offre.cfa_delegated_siret }) : null + const cfa = offre.is_delegated ? await Cfa.findOne({ siret: offre.cfa_delegated_siret }) : null if (typeof offre.rome_detail !== "string" && offre.rome_detail) { offre.job_type.map(async (type) => { if (offre.rome_detail && typeof offre.rome_detail !== "string") { - buffer.push({ ...offre, type: type, cfa: user ? pick(user, ["address_detail", "establishment_raison_sociale"]) : null }) + const cfaFields = cfa ? { address_detail: cfa.address_detail, establishment_raison_sociale: cfa.raison_sociale } : null + buffer.push({ ...offre, type, cfa: cfaFields }) } else { stat.ko++ } From 26dae98905438e516369efa59482c3da5a5bfaa0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9my=20Auricoste?= Date: Tue, 27 Feb 2024 12:32:09 +0100 Subject: [PATCH 16/78] fix: refactor application.service --- .../common/model/schema/jobs/jobs.schema.ts | 4 ++ server/src/jobs/multiCompte/migrationUsers.ts | 72 +++++++++++++++++-- server/src/security/accessTokenService.ts | 1 + server/src/services/appLinks.service.ts | 4 +- server/src/services/application.service.ts | 49 ++++++++----- shared/models/job.model.ts | 1 + 6 files changed, 106 insertions(+), 25 deletions(-) diff --git a/server/src/common/model/schema/jobs/jobs.schema.ts b/server/src/common/model/schema/jobs/jobs.schema.ts index 0adf3f0bea..05dda3665f 100644 --- a/server/src/common/model/schema/jobs/jobs.schema.ts +++ b/server/src/common/model/schema/jobs/jobs.schema.ts @@ -153,6 +153,10 @@ export const jobsSchema = new Schema( type: Number, description: "Nombre de vues sur une page de recherche", }, + managed_by: { + type: String, + description: "Id de l'utilisateur gérant l'offre", + }, }, { versionKey: false, diff --git a/server/src/jobs/multiCompte/migrationUsers.ts b/server/src/jobs/multiCompte/migrationUsers.ts index 5b83d1278c..0c2df14a6e 100644 --- a/server/src/jobs/multiCompte/migrationUsers.ts +++ b/server/src/jobs/multiCompte/migrationUsers.ts @@ -2,6 +2,7 @@ import dayjs from "dayjs" import { AppointmentUserType } from "shared/constants/appointment.js" import { EApplicantRole } from "shared/constants/rdva.js" import { ENTREPRISE, ETAT_UTILISATEUR, OPCOS, VALIDATION_UTILISATEUR } from "shared/constants/recruteur.js" +import { IRecruiter } from "shared/index.js" import { ICFA } from "shared/models/cfa.model.js" import { EntrepriseStatus, IEntreprise, IEntrepriseStatusEvent } from "shared/models/entreprise.model.js" import { AccessEntityType, AccessStatus, IRoleManagement, IRoleManagementEvent } from "shared/models/roleManagement.model.js" @@ -9,7 +10,7 @@ import { IUser2, IUserStatusEvent, UserEventType } from "shared/models/user2.mod import { IUserRecruteur } from "shared/models/usersRecruteur.model.js" import { logger } from "../../common/logger.js" -import { Appointment, User, UserRecruteur } from "../../common/model/index.js" +import { Appointment, Recruiter, User, UserRecruteur } from "../../common/model/index.js" import { Cfa } from "../../common/model/schema/multiCompte/cfa.schema.js" import { Entreprise } from "../../common/model/schema/multiCompte/entreprise.schema.js" import { RoleManagement } from "../../common/model/schema/multiCompte/roleManagement.schema.js" @@ -24,13 +25,72 @@ export const migrationUsers = async () => { await Cfa.deleteMany({}) await RoleManagement.deleteMany({}) const now = new Date() - await migrationRecruteurs() + await migrationRecruiters() + await migrationUserRecruteurs() await migrationCandidats(now) } -const migrationRecruteurs = async () => { +const migrationRecruiters = async () => { + logger.info(`Migration: lecture des recruiteurs...`) + const recruiters: IRecruiter[] = await Recruiter.find({}).lean() + logger.info(`Migration: ${recruiters.length} recruiteurs à mettre à jour`) + const stats = { success: 0, failure: 0, jobSuccess: 0 } + + await asyncForEachGrouped(recruiters, 100, async (recruiter, index) => { + index % 1000 === 0 && logger.info(`import du recruiteur n°${index}`) + try { + const { establishment_id, cfa_delegated_siret, jobs } = recruiter + let userRecruiter: IUserRecruteur + if (cfa_delegated_siret) { + userRecruiter = await UserRecruteur.findOne({ establishment_siret: cfa_delegated_siret }).lean() + if (!userRecruiter) { + throw new Error(`inattendu: impossible de trouver le user recruteur avec establishment_siret=${cfa_delegated_siret}`) + } + } else { + userRecruiter = await UserRecruteur.findOne({ establishment_id }).lean() + if (!userRecruiter) { + throw new Error(`inattendu: impossible de trouver le user recruteur avec establishment_id=${establishment_id}`) + } + } + + await Promise.all( + jobs.map(async (job) => { + await Recruiter.findOneAndUpdate( + { "jobs._id": job._id }, + { + $set: { + "jobs.$.managed_by": userRecruiter._id, + }, + }, + { new: true } + ).lean() + stats.jobSuccess++ + }) + ) + stats.success++ + } catch (err) { + logger.error(`erreur lors de l'import du user recruteur avec id=${recruiter._id}`) + logger.error(err) + stats.failure++ + } + }) + logger.info(`Migration: user candidats terminés`) + const message = `${stats.success} recruiteurs repris avec succès. + ${stats.failure} recruiteurs en erreur. + ${stats.jobSuccess} offres reprises avec succès. + ` + logger.info(message) + await notifyToSlack({ + subject: "Migration multi-compte", + message, + error: stats.failure > 0, + }) + return stats +} + +const migrationUserRecruteurs = async () => { logger.info(`Migration: lecture des user recruteurs...`) - const userRecruteurs: IUserRecruteur[] = await UserRecruteur.find({}) + const userRecruteurs: IUserRecruteur[] = await UserRecruteur.find({}).lean() logger.info(`Migration: ${userRecruteurs.length} user recruteurs à mettre à jour`) const stats = { success: 0, failure: 0, entrepriseCreated: 0, cfaCreated: 0, userCreated: 0, adminAccess: 0, opcoAccess: 0 } @@ -207,7 +267,7 @@ const migrationCandidats = async (now: Date) => { logger.info(`Migration: lecture des user candidats...`) // l'utilisateur admin n'est pas repris - const candidats = await User.find({ role: EApplicantRole.CANDIDAT }) + const candidats = await User.find({ role: EApplicantRole.CANDIDAT }).lean() logger.info(`Migration: ${candidats.length} user candidats à mettre à jour`) const stats = { success: 0, failure: 0, alreadyExist: 0 } @@ -219,7 +279,7 @@ const migrationCandidats = async (now: Date) => { if (type) { await Appointment.updateMany({ applicant_id: candidat._id }, { $set: { applicant_type: parseEnumOrError(AppointmentUserType, type) } }) } - const existingUser = await User2.findOne({ email }) + const existingUser = await User2.findOne({ email }).lean() if (existingUser) { await Appointment.updateMany({ applicant_id: candidat._id }, { $set: { applicant_id: existingUser._id } }) if (dayjs(candidat.last_action_date).isAfter(existingUser.last_action_date)) { diff --git a/server/src/security/accessTokenService.ts b/server/src/security/accessTokenService.ts index fe5045579f..c14a69134a 100644 --- a/server/src/security/accessTokenService.ts +++ b/server/src/security/accessTokenService.ts @@ -72,6 +72,7 @@ export type IAccessToken } | { type: "lba-company"; siret: string; email: string } | { type: "candidat"; email: string } + | { type: "IUser2"; email: string; _id: string } scopes: ReadonlyArray> } diff --git a/server/src/services/appLinks.service.ts b/server/src/services/appLinks.service.ts index f15a9c44af..45e3bfc170 100644 --- a/server/src/services/appLinks.service.ts +++ b/server/src/services/appLinks.service.ts @@ -80,7 +80,7 @@ export function createCfaUnsubscribeToken(email: string, siret: string) { ) } -export function createCancelJobLink(user: IUserRecruteur, jobId: string, utmData: string | undefined = undefined) { +export function createCancelJobLink(user: UserForAccessToken, jobId: string, utmData: string | undefined = undefined) { const token = generateAccessToken( user, [ @@ -102,7 +102,7 @@ export function createCancelJobLink(user: IUserRecruteur, jobId: string, utmData return `${config.publicUrl}/espace-pro/offre/${jobId}/cancel?${utmData ? utmData : ""}&token=${token}` } -export function createProvidedJobLink(user: IUserRecruteur, jobId: string, utmData: string | undefined = undefined) { +export function createProvidedJobLink(user: UserForAccessToken, jobId: string, utmData: string | undefined = undefined) { const token = generateAccessToken( user, [ diff --git a/server/src/services/application.service.ts b/server/src/services/application.service.ts index 1b6ba132d3..0480d99a5c 100644 --- a/server/src/services/application.service.ts +++ b/server/src/services/application.service.ts @@ -3,17 +3,18 @@ import { isEmailBurner } from "burner-email-providers" import Joi from "joi" import type { EnforceDocument } from "mongoose" import { oleoduc, writeData } from "oleoduc" -import { IApplication, IJob, ILbaCompany, INewApplication, IRecruiter, IUserRecruteur, JOB_STATUS, ZApplication, assertUnreachable } from "shared" +import { IApplication, IJob, ILbaCompany, INewApplication, IRecruiter, JOB_STATUS, ZApplication, assertUnreachable } from "shared" import { ApplicantIntention } from "shared/constants/application" import { BusinessErrorCodes } from "shared/constants/errorCodes" import { RECRUITER_STATUS } from "shared/constants/recruteur" import { prepareMessageForMail, removeUrlsFromText } from "shared/helpers/common" +import { IUser2 } from "shared/models/user2.model" import { getStaticFilePath } from "@/common/utils/getStaticFilePath" import { UserForAccessToken } from "@/security/accessTokenService" import { logger } from "../common/logger" -import { Application, EmailBlacklist, LbaCompany, Recruiter, UserRecruteur } from "../common/model" +import { Application, EmailBlacklist, LbaCompany, Recruiter, User2 } from "../common/model" import { manageApiError } from "../common/utils/errorManager" import { sentryCaptureException } from "../common/utils/sentryUtils" import config from "../config" @@ -260,7 +261,7 @@ const buildUrlsOfDetail = (publicUrl: string, newApplication: INewApplication) = } } -const buildUserToken = (application: IApplication, userRecruteur?: IUserRecruteur): UserForAccessToken => { +const buildUserForToken = (application: IApplication, userRecruteur?: IUser2): UserForAccessToken => { const { job_origin, company_siret, company_email } = application if (job_origin === "lba") { return { type: "lba-company", siret: company_siret, email: company_email } @@ -268,13 +269,13 @@ const buildUserToken = (application: IApplication, userRecruteur?: IUserRecruteu if (!userRecruteur) { throw Boom.internal("un user recruteur était attendu") } - return userRecruteur + return { type: "IUser2", email: userRecruteur.email, _id: userRecruteur._id.toString() } } else { throw Boom.internal(`job_origin=${job_origin} non supporté`) } } -const buildReplyLink = (application: IApplication, intention: ApplicantIntention, userRecruteur?: IUserRecruteur) => { +const buildReplyLink = (application: IApplication, intention: ApplicantIntention, userForToken: UserForAccessToken) => { const applicationId = application._id.toString() const searchParams = new URLSearchParams() searchParams.append("company_recruitment_intention", intention) @@ -284,11 +285,28 @@ const buildReplyLink = (application: IApplication, intention: ApplicantIntention searchParams.append("utm_source", "jecandidate") searchParams.append("utm_medium", "email") searchParams.append("utm_campaign", "jecandidaterecruteur") - const token = generateApplicationReplyToken(buildUserToken(application, userRecruteur), applicationId) + const token = generateApplicationReplyToken(userForToken, applicationId) searchParams.append("token", token) return `${config.publicUrl}/formulaire-intention?${searchParams.toString()}` } +const getUser2ManagingOffer = async (recruiter: IRecruiter, jobId: string) => { + const job = recruiter.jobs.find((job) => job._id.toString() === jobId) + if (!job) { + throw new Error(`unexpected: could not find offer with id=${jobId}`) + } + const { managed_by } = job + if (managed_by) { + const user = await User2.findOne({ _id: managed_by }).lean() + if (!user) { + throw new Error(`could not find offer manager with id=${managed_by}`) + } + return user + } else { + throw new Error(`unexpected: managed_by is empty for offer with id=${jobId}`) + } +} + /** * Build urls to add in email messages sent to the recruiter */ @@ -296,22 +314,19 @@ const buildRecruiterEmailUrls = async (application: IApplication) => { const utmRecruiterData = "&utm_source=jecandidate&utm_medium=email&utm_campaign=jecandidaterecruteur" // get the related recruiters to fetch it's establishment_id - let userRecruteur: IUserRecruteur | undefined + let userRecruteur: IUser2 | undefined if (application.job_id) { const recruiter = await Recruiter.findOne({ "jobs._id": application.job_id }).lean() if (recruiter) { - if (recruiter.is_delegated) { - userRecruteur = await UserRecruteur.findOne({ establishment_siret: recruiter.cfa_delegated_siret }).lean() - } else { - userRecruteur = await UserRecruteur.findOne({ establishment_id: recruiter.establishment_id }).lean() - } + userRecruteur = await getUser2ManagingOffer(recruiter, application.job_id) } } + const userForToken = buildUserForToken(application, userRecruteur) const urls = { - meetCandidateUrl: buildReplyLink(application, ApplicantIntention.ENTRETIEN, userRecruteur), - waitCandidateUrl: buildReplyLink(application, ApplicantIntention.NESAISPAS, userRecruteur), - refuseCandidateUrl: buildReplyLink(application, ApplicantIntention.REFUS, userRecruteur), + meetCandidateUrl: buildReplyLink(application, ApplicantIntention.ENTRETIEN, userForToken), + waitCandidateUrl: buildReplyLink(application, ApplicantIntention.NESAISPAS, userForToken), + refuseCandidateUrl: buildReplyLink(application, ApplicantIntention.REFUS, userForToken), lbaRecruiterUrl: `${config.publicUrl}/acces-recruteur?${utmRecruiterData}`, unsubscribeUrl: `${config.publicUrl}/desinscription?email=${application.company_email}${utmRecruiterData}`, lbaUrl: `${config.publicUrl}?${utmRecruiterData}`, @@ -321,8 +336,8 @@ const buildRecruiterEmailUrls = async (application: IApplication) => { } if (application.job_id && userRecruteur) { - urls.jobProvidedUrl = createProvidedJobLink(userRecruteur, application.job_id, utmRecruiterData) - urls.cancelJobUrl = createCancelJobLink(userRecruteur, application.job_id, utmRecruiterData) + urls.jobProvidedUrl = createProvidedJobLink(userForToken, application.job_id, utmRecruiterData) + urls.cancelJobUrl = createCancelJobLink(userForToken, application.job_id, utmRecruiterData) } return urls diff --git a/shared/models/job.model.ts b/shared/models/job.model.ts index 55115e28e9..b182671768 100644 --- a/shared/models/job.model.ts +++ b/shared/models/job.model.ts @@ -65,6 +65,7 @@ export const ZJobFields = z custom_geo_coordinates: z.string().nullish().describe("Latitude/Longitude de l'adresse personnalisée de l'entreprise"), stats_detail_view: z.number().nullish().describe("Nombre de vues de la page de détail"), stats_search_view: z.number().nullish().describe("Nombre de vues sur une page de recherche"), + managed_by: zObjectId.nullish().describe("Id de l'utilisateur gérant l'offre"), }) .strict() .openapi("JobWritable") From b8e3b24adc682afad6ea0160aa87b45a44a9efcc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9my=20Auricoste?= Date: Tue, 27 Feb 2024 12:39:50 +0100 Subject: [PATCH 17/78] fix: refactor email validation --- .../controllers/etablissementRecruteur.controller.ts | 6 ++---- server/src/services/etablissement.service.ts | 10 +--------- 2 files changed, 3 insertions(+), 13 deletions(-) diff --git a/server/src/http/controllers/etablissementRecruteur.controller.ts b/server/src/http/controllers/etablissementRecruteur.controller.ts index a3f514b534..b68ff4549a 100644 --- a/server/src/http/controllers/etablissementRecruteur.controller.ts +++ b/server/src/http/controllers/etablissementRecruteur.controller.ts @@ -21,7 +21,6 @@ import { getOrganismeDeFormationDataFromSiret, sendUserConfirmationEmail, validateCreationEntrepriseFromCfa, - validateEtablissementEmail, } from "../../services/etablissement.service" import { autoValidateUser, @@ -267,10 +266,9 @@ export default (server: Server) => { }, async (req, res) => { const user = getUserFromRequest(req, zRoutes.post["/etablissement/validation"]).value + const email = user.identity.email.toLocaleLowerCase() - // Validate email - const userRecruteur = await validateEtablissementEmail(user.identity.email.toLocaleLowerCase()) - + const userRecruteur = await getUserRecruteurByEmail(email) if (!userRecruteur) { throw Boom.badRequest("La validation de l'adresse mail a échoué. Merci de contacter le support La bonne alternance.") } diff --git a/server/src/services/etablissement.service.ts b/server/src/services/etablissement.service.ts index d7f0a423a9..b9d2773366 100644 --- a/server/src/services/etablissement.service.ts +++ b/server/src/services/etablissement.service.ts @@ -12,7 +12,7 @@ import { FCGetOpcoInfos } from "@/common/franceCompetencesClient" import { getStaticFilePath } from "@/common/utils/getStaticFilePath" import { getHttpClient } from "@/common/utils/httpUtils" -import { Etablissement, LbaCompany, LbaCompanyLegacy, ReferentielOpco, SiretDiffusibleStatus, UnsubscribeOF, UserRecruteur } from "../common/model/index" +import { Etablissement, LbaCompany, LbaCompanyLegacy, ReferentielOpco, SiretDiffusibleStatus, UnsubscribeOF } from "../common/model/index" import { isEmailFromPrivateCompany, isEmailSameDomain } from "../common/utils/mailUtils" import { sentryCaptureException } from "../common/utils/sentryUtils" import config from "../config" @@ -240,14 +240,6 @@ export const getIdcc = async (siret: string): Promise => { } } -/** - * @description Validate the establishment email for a given ID - * @param {IUserRecruteur["_id"]} _id - * @returns {Promise} - */ -export const validateEtablissementEmail = async (email: IUserRecruteur["email"]): Promise => - UserRecruteur.findOneAndUpdate({ email }, { is_email_checked: true }) - /** * @description Get the establishment information from the ENTREPRISE API for a given SIRET */ From c8cbddbda20ade6e713c3bb2018f0d1235ecdc64 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9my=20Auricoste?= Date: Tue, 27 Feb 2024 15:34:54 +0100 Subject: [PATCH 18/78] fix: refactor utilisation de getUser --- server/src/services/application.service.ts | 12 +++----- server/src/services/etablissement.service.ts | 23 +++++++++++--- server/src/services/formulaire.service.ts | 32 +++++++++++++------- server/src/services/lbajob.service.ts | 30 +++++++++--------- 4 files changed, 58 insertions(+), 39 deletions(-) diff --git a/server/src/services/application.service.ts b/server/src/services/application.service.ts index 0480d99a5c..72e0479877 100644 --- a/server/src/services/application.service.ts +++ b/server/src/services/application.service.ts @@ -22,7 +22,7 @@ import config from "../config" import { createCancelJobLink, createProvidedJobLink, generateApplicationReplyToken } from "./appLinks.service" import { BrevoEventStatus } from "./brevo.service" import { scan } from "./clamav.service" -import { getOffreAvecInfoMandataire } from "./formulaire.service" +import { getOffreAvecInfoMandataire, getJobFromRecruiter } from "./formulaire.service" import { buildLbaCompanyAddress } from "./lbacompany.service" import mailer, { sanitizeForEmail } from "./mailer.service" import { validateCaller } from "./queryValidator.service" @@ -290,11 +290,7 @@ const buildReplyLink = (application: IApplication, intention: ApplicantIntention return `${config.publicUrl}/formulaire-intention?${searchParams.toString()}` } -const getUser2ManagingOffer = async (recruiter: IRecruiter, jobId: string) => { - const job = recruiter.jobs.find((job) => job._id.toString() === jobId) - if (!job) { - throw new Error(`unexpected: could not find offer with id=${jobId}`) - } +export const getUser2ManagingOffer = async (job: Pick): Promise => { const { managed_by } = job if (managed_by) { const user = await User2.findOne({ _id: managed_by }).lean() @@ -303,7 +299,7 @@ const getUser2ManagingOffer = async (recruiter: IRecruiter, jobId: string) => { } return user } else { - throw new Error(`unexpected: managed_by is empty for offer with id=${jobId}`) + throw new Error(`unexpected: managed_by is empty for offer with id=${job._id}`) } } @@ -318,7 +314,7 @@ const buildRecruiterEmailUrls = async (application: IApplication) => { if (application.job_id) { const recruiter = await Recruiter.findOne({ "jobs._id": application.job_id }).lean() if (recruiter) { - userRecruteur = await getUser2ManagingOffer(recruiter, application.job_id) + userRecruteur = await getUser2ManagingOffer(getJobFromRecruiter(recruiter, application.job_id)) } } diff --git a/server/src/services/etablissement.service.ts b/server/src/services/etablissement.service.ts index b9d2773366..4b626c5242 100644 --- a/server/src/services/etablissement.service.ts +++ b/server/src/services/etablissement.service.ts @@ -7,12 +7,14 @@ import { IBusinessError, ICfaReferentielData, IEtablissement, ILbaCompany, IRecr import { EDiffusibleStatus } from "shared/constants/diffusibleStatus" import { BusinessErrorCodes } from "shared/constants/errorCodes" import { ETAT_UTILISATEUR } from "shared/constants/recruteur" +import { AccessEntityType, AccessStatus } from "shared/models/roleManagement.model" +import { getLastStatusEvent } from "shared/utils/getLastStatusEvent" import { FCGetOpcoInfos } from "@/common/franceCompetencesClient" import { getStaticFilePath } from "@/common/utils/getStaticFilePath" import { getHttpClient } from "@/common/utils/httpUtils" -import { Etablissement, LbaCompany, LbaCompanyLegacy, ReferentielOpco, SiretDiffusibleStatus, UnsubscribeOF } from "../common/model/index" +import { Cfa, Etablissement, LbaCompany, LbaCompanyLegacy, ReferentielOpco, RoleManagement, SiretDiffusibleStatus, UnsubscribeOF } from "../common/model/index" import { isEmailFromPrivateCompany, isEmailSameDomain } from "../common/utils/mailUtils" import { sentryCaptureException } from "../common/utils/sentryUtils" import config from "../config" @@ -20,7 +22,7 @@ import config from "../config" import { createValidationMagicLink } from "./appLinks.service" import { validationOrganisation } from "./bal.service" import { getCatalogueEtablissements } from "./catalogue.service" -import { CFA, ENTREPRISE, RECRUITER_STATUS } from "./constant.service" +import { ENTREPRISE, RECRUITER_STATUS } from "./constant.service" import dayjs from "./dayjs.service" import { IAPIAdresse, @@ -35,7 +37,7 @@ import { import { createFormulaire, getFormulaire } from "./formulaire.service" import mailer, { sanitizeForEmail } from "./mailer.service" import { getOpcoBySirenFromDB, saveOpco } from "./opco.service" -import { autoValidateUser, createUser, getUser, getUserRecruteurByEmail, getUserStatus, setUserHasToBeManuallyValidated, setUserInError } from "./userRecruteur.service" +import { autoValidateUser, createUser, getUserRecruteurByEmail, getUserStatus, setUserHasToBeManuallyValidated, setUserInError } from "./userRecruteur.service" const apiParams = { token: config.entreprise.apiKey, @@ -675,10 +677,21 @@ export const getEntrepriseDataFromSiret = async ({ siret, cfa_delegated_siret }: return { ...entrepriseData, geo_coordinates: `${latitude},${longitude}`, geopoint: { type: "Point", coordinates: [longitude, latitude] as [number, number] } } } +const isCfaCreationValid = async (siret: string): Promise => { + const cfa = await Cfa.findOne({ siret }).lean() + if (!cfa) return true + const role = await RoleManagement.findOne({ authorized_type: AccessEntityType.CFA, authorized_id: cfa._id.toString() }).lean() + if (!role) return true + if (getLastStatusEvent(role.status)?.status !== AccessStatus.DENIED) { + return false + } + return true +} + export const getOrganismeDeFormationDataFromSiret = async (siret: string, shouldValidate = true) => { if (shouldValidate) { - const cfaUserRecruteurOpt = await getUser({ establishment_siret: siret, type: CFA }) - if (cfaUserRecruteurOpt) { + const isValid = await isCfaCreationValid(siret) + if (!isValid) { throw Boom.forbidden("Ce numéro siret est déjà associé à un compte utilisateur.", { reason: BusinessErrorCodes.ALREADY_EXISTS }) } } diff --git a/server/src/services/formulaire.service.ts b/server/src/services/formulaire.service.ts index 3776ab6f31..a7bbc259c9 100644 --- a/server/src/services/formulaire.service.ts +++ b/server/src/services/formulaire.service.ts @@ -8,10 +8,11 @@ import { ETAT_UTILISATEUR, RECRUITER_STATUS } from "shared/constants/recruteur" import { db } from "@/common/mongodb" import { getStaticFilePath } from "@/common/utils/getStaticFilePath" -import { Recruiter, UnsubscribeOF } from "../common/model/index" +import { Cfa, Recruiter, UnsubscribeOF } from "../common/model/index" import { asyncForEach } from "../common/utils/asyncUtils" import config from "../config" +import { getUser2ManagingOffer } from "./application.service" import { createCfaUnsubscribeToken, createViewDelegationLink } from "./appLinks.service" import { getCatalogueEtablissements, getCatalogueFormations } from "./catalogue.service" import dayjs from "./dayjs.service" @@ -31,12 +32,12 @@ export interface IOffreExtended extends IJob { /** * @description get formulaire by offer id */ -export const getOffreAvecInfoMandataire = async (id: string | ObjectIdType): Promise<{ recruiter: IRecruiter; job: IJob } | null> => { - const recruiterOpt = await getOffre(id) +export const getOffreAvecInfoMandataire = async (jobId: string | ObjectIdType): Promise<{ recruiter: IRecruiter; job: IJob } | null> => { + const recruiterOpt = await getOffre(jobId) if (!recruiterOpt) { return null } - const job = recruiterOpt.jobs.find((x) => x._id.toString() === id.toString()) + const job = recruiterOpt.jobs.find((x) => x._id.toString() === jobId.toString()) if (!job) { return null } @@ -44,14 +45,15 @@ export const getOffreAvecInfoMandataire = async (id: string | ObjectIdType): Pro if (recruiterOpt.is_delegated && recruiterOpt.address) { const { cfa_delegated_siret } = recruiterOpt if (cfa_delegated_siret) { - const cfa = await getUser({ establishment_siret: cfa_delegated_siret }) - + const cfa = await Cfa.findOne({ siret: cfa_delegated_siret }).lean() if (cfa) { - recruiterOpt.phone = cfa.phone - recruiterOpt.email = cfa.email - recruiterOpt.last_name = cfa.last_name - recruiterOpt.first_name = cfa.first_name - recruiterOpt.establishment_raison_sociale = cfa.establishment_raison_sociale + const cfaUser = await getUser2ManagingOffer(getJobFromRecruiter(recruiterOpt, jobId.toString())) + + recruiterOpt.phone = cfaUser.phone + recruiterOpt.email = cfaUser.email + recruiterOpt.last_name = cfaUser.last_name + recruiterOpt.first_name = cfaUser.first_name + recruiterOpt.establishment_raison_sociale = cfa.raison_sociale recruiterOpt.address = cfa.address return { recruiter: recruiterOpt, job } } @@ -596,3 +598,11 @@ export async function sendMailNouvelleOffre(recruiter: IRecruiter, job: IJob, co export function addExpirationPeriod(fromDate: Date | dayjs.Dayjs): dayjs.Dayjs { return dayjs(fromDate).add(2, "months") } + +export const getJobFromRecruiter = (recruiter: IRecruiter, jobId: string): IJob => { + const job = recruiter.jobs.find((job) => job._id.toString() === jobId) + if (!job) { + throw new Error(`could not find job with id=${jobId} in recruiter with id=${recruiter._id}`) + } + return job +} diff --git a/server/src/services/lbajob.service.ts b/server/src/services/lbajob.service.ts index d052c75997..798179c85c 100644 --- a/server/src/services/lbajob.service.ts +++ b/server/src/services/lbajob.service.ts @@ -1,7 +1,7 @@ import { IJob, IRecruiter, JOB_STATUS } from "shared" import { RECRUITER_STATUS } from "shared/constants/recruteur" -import { Recruiter } from "@/common/model" +import { Cfa, Recruiter } from "@/common/model" import { encryptMailWithIV } from "../common/utils/encryptString" import { IApiError, manageApiError } from "../common/utils/errorManager" @@ -9,12 +9,11 @@ import { roundDistance } from "../common/utils/geolib" import { trackApiCall } from "../common/utils/sendTrackingEvent" import { sentryCaptureException } from "../common/utils/sentryUtils" -import { IApplicationCount, getApplicationByJobCount } from "./application.service" +import { IApplicationCount, getApplicationByJobCount, getUser2ManagingOffer } from "./application.service" import { NIVEAUX_POUR_LBA } from "./constant.service" import { getOffreAvecInfoMandataire, incrementLbaJobViewCount } from "./formulaire.service" import { ILbaItemLbaJob } from "./lbaitem.shared.service.types" import { filterJobsByOpco } from "./opco.service" -import { getUser } from "./userRecruteur.service" const JOB_SEARCH_LIMIT = 250 @@ -66,21 +65,22 @@ export const getJobs = async ({ distance, lat, lon, romes, niveau }: { distance: const jobs: IRecruiter[] = await Recruiter.aggregate(stages) const filteredJobs = await Promise.all( - jobs.map(async (x) => { + jobs.map(async (job) => { const jobs: any[] = [] - if (x.is_delegated) { - const cfa = await getUser({ establishment_siret: x.cfa_delegated_siret }) + if (job.is_delegated && job.cfa_delegated_siret) { + const cfa = await Cfa.findOne({ siret: job.cfa_delegated_siret }) + const cfaUser = await getUser2ManagingOffer(job) - x.phone = cfa?.phone - x.email = cfa?.email || "" - x.last_name = cfa?.last_name - x.first_name = cfa?.first_name - x.establishment_raison_sociale = cfa?.establishment_raison_sociale - x.address = cfa?.address + job.phone = cfaUser.phone + job.email = cfaUser.email + job.last_name = cfaUser.last_name + job.first_name = cfaUser.first_name + job.establishment_raison_sociale = cfa?.raison_sociale + job.address = cfa?.address } - x.jobs.forEach((o) => { + job.jobs.forEach((o) => { if (romes.some((item) => o.rome_code.includes(item)) && o.job_status === JOB_STATUS.ACTIVE) { o.rome_label = o.rome_appellation_label ?? o.rome_label if (!niveau || NIVEAUX_POUR_LBA["INDIFFERENT"] === o.job_level_label || niveau === o.job_level_label) { @@ -89,8 +89,8 @@ export const getJobs = async ({ distance, lat, lon, romes, niveau }: { distance: } }) - x.jobs = jobs - return x + job.jobs = jobs + return job }) ) From 04aad263773937b81b77b1af2ec6d4066c2e5486 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9my=20Auricoste?= Date: Tue, 27 Feb 2024 17:22:14 +0100 Subject: [PATCH 19/78] fix: suppression de la methode getUser --- .../user/misc/updateSiretInfosInError.ts | 41 ++++++++----- server/src/security/accessTokenService.ts | 3 + server/src/services/appLinks.service.ts | 2 +- server/src/services/application.service.ts | 8 +-- server/src/services/etablissement.service.ts | 14 +++-- server/src/services/formulaire.service.ts | 60 +++++++++---------- server/src/services/userRecruteur.service.ts | 35 ++++++++--- 7 files changed, 100 insertions(+), 63 deletions(-) diff --git a/server/src/jobs/lba_recruteur/user/misc/updateSiretInfosInError.ts b/server/src/jobs/lba_recruteur/user/misc/updateSiretInfosInError.ts index ad5f105f2c..0e2fde481c 100644 --- a/server/src/jobs/lba_recruteur/user/misc/updateSiretInfosInError.ts +++ b/server/src/jobs/lba_recruteur/user/misc/updateSiretInfosInError.ts @@ -1,16 +1,20 @@ import Boom from "boom" import { JOB_STATUS, type IUserRecruteur } from "shared" import { ETAT_UTILISATEUR, RECRUITER_STATUS } from "shared/constants/recruteur" +import { AccessEntityType, AccessStatus } from "shared/models/roleManagement.model" +import { getLastStatusEvent } from "shared/utils/getLastStatusEvent" + +import { getUser2ManagingOffer } from "@/services/application.service" import { logger } from "../../../../common/logger" -import { Recruiter, UserRecruteur } from "../../../../common/model/index" +import { Cfa, Recruiter, RoleManagement, UserRecruteur } from "../../../../common/model/index" import { asyncForEach } from "../../../../common/utils/asyncUtils" import { sentryCaptureException } from "../../../../common/utils/sentryUtils" import { notifyToSlack } from "../../../../common/utils/slackUtils" import { CFA, ENTREPRISE } from "../../../../services/constant.service" -import { autoValidateCompany, EntrepriseData, getEntrepriseDataFromSiret, sendEmailConfirmationEntreprise } from "../../../../services/etablissement.service" +import { EntrepriseData, autoValidateCompany, getEntrepriseDataFromSiret, sendEmailConfirmationEntreprise } from "../../../../services/etablissement.service" import { activateEntrepriseRecruiterForTheFirstTime, archiveFormulaire, getFormulaire, sendMailNouvelleOffre, updateFormulaire } from "../../../../services/formulaire.service" -import { autoValidateUser, deactivateUser, getUser, setUserInError, updateUser } from "../../../../services/userRecruteur.service" +import { autoValidateUser, deactivateUser, setUserInError, updateUser } from "../../../../services/userRecruteur.service" const updateUserRecruteursSiretInfosInError = async () => { const userRecruteurs = await UserRecruteur.find({ @@ -84,19 +88,26 @@ const updateRecruteursSiretInfosInError = async () => { } else { const entrepriseData: Partial = siretResponse const updatedRecruiter = await updateFormulaire(establishment_id, { ...entrepriseData, status: RECRUITER_STATUS.ACTIF }) - const userRecruteurCFA = await getUser({ establishment_siret: cfa_delegated_siret, $expr: { $eq: [{ $arrayElemAt: ["$status.status", -1] }, ETAT_UTILISATEUR.VALIDE] } }) - if (!userRecruteurCFA) { - throw Boom.internal(`unexpected: impossible de trouver le user recruteur CFA avec siret=${cfa_delegated_siret}`) + const managingUser = await getUser2ManagingOffer(updatedRecruiter.jobs[0]) + const cfa = await Cfa.findOne({ siret: cfa_delegated_siret }).lean() + if (!cfa) { + throw Boom.internal(`could not find cfa with siret=${cfa_delegated_siret}`) + } + const role = await RoleManagement.findOne({ user_id: managingUser._id, authorized_type: AccessEntityType.CFA, authorized_id: cfa._id.toString() }).lean() + if (!role) { + throw Boom.internal(`could not find role with user_id=${managingUser._id} and authorized_id=${cfa._id}`) + } + if (getLastStatusEvent(role.status)?.status === AccessStatus.GRANTED) { + await Promise.all( + updatedRecruiter.jobs.flatMap((job) => { + if (job.job_status === JOB_STATUS.ACTIVE) { + return [sendMailNouvelleOffre(updatedRecruiter, job, managingUser)] + } else { + return [] + } + }) + ) } - await Promise.all( - updatedRecruiter.jobs.flatMap((job) => { - if (job.job_status === JOB_STATUS.ACTIVE) { - return [sendMailNouvelleOffre(updatedRecruiter, job, userRecruteurCFA)] - } else { - return [] - } - }) - ) stats.success++ } } catch (err) { diff --git a/server/src/security/accessTokenService.ts b/server/src/security/accessTokenService.ts index c14a69134a..9c70743c59 100644 --- a/server/src/security/accessTokenService.ts +++ b/server/src/security/accessTokenService.ts @@ -2,6 +2,7 @@ import Boom from "boom" import jwt from "jsonwebtoken" import { PathParam, QueryString } from "shared/helpers/generateUri" import { IUserRecruteur } from "shared/models" +import { IUser2 } from "shared/models/user2.model" import { IRouteSchema, WithSecurityScheme } from "shared/routes/common.routes" import { assertUnreachable } from "shared/utils" import { Jsonify } from "type-fest" @@ -78,6 +79,8 @@ export type IAccessToken export type UserForAccessToken = IUserRecruteur | IAccessToken["identity"] +export const user2ToUserForToken = (user: IUser2): UserForAccessToken => ({ type: "IUser2", _id: user._id.toString(), email: user.email }) + export function generateAccessToken(user: UserForAccessToken, scopes: ReadonlyArray>, options: { expiresIn?: string } = {}): string { const identity: IAccessToken["identity"] = "_id" in user ? { type: "IUserRecruteur", _id: user._id.toString(), email: user.email.toLowerCase() } : user const data: IAccessToken = { diff --git a/server/src/services/appLinks.service.ts b/server/src/services/appLinks.service.ts index 45e3bfc170..fd7d97a9ad 100644 --- a/server/src/services/appLinks.service.ts +++ b/server/src/services/appLinks.service.ts @@ -22,7 +22,7 @@ export function createAuthMagicLink(user: IUserRecruteur) { return `${config.publicUrl}/espace-pro/authentification/verification?token=${encodeURIComponent(token)}` } -export function createValidationMagicLink(user: IUserRecruteur) { +export function createValidationMagicLink(user: UserForAccessToken) { const token = generateAccessToken( user, [ diff --git a/server/src/services/application.service.ts b/server/src/services/application.service.ts index 72e0479877..1f0ed57c2f 100644 --- a/server/src/services/application.service.ts +++ b/server/src/services/application.service.ts @@ -11,7 +11,7 @@ import { prepareMessageForMail, removeUrlsFromText } from "shared/helpers/common import { IUser2 } from "shared/models/user2.model" import { getStaticFilePath } from "@/common/utils/getStaticFilePath" -import { UserForAccessToken } from "@/security/accessTokenService" +import { UserForAccessToken, user2ToUserForToken } from "@/security/accessTokenService" import { logger } from "../common/logger" import { Application, EmailBlacklist, LbaCompany, Recruiter, User2 } from "../common/model" @@ -261,15 +261,15 @@ const buildUrlsOfDetail = (publicUrl: string, newApplication: INewApplication) = } } -const buildUserForToken = (application: IApplication, userRecruteur?: IUser2): UserForAccessToken => { +const buildUserForToken = (application: IApplication, user?: IUser2): UserForAccessToken => { const { job_origin, company_siret, company_email } = application if (job_origin === "lba") { return { type: "lba-company", siret: company_siret, email: company_email } } else if (job_origin === "matcha") { - if (!userRecruteur) { + if (!user) { throw Boom.internal("un user recruteur était attendu") } - return { type: "IUser2", email: userRecruteur.email, _id: userRecruteur._id.toString() } + return user2ToUserForToken(user) } else { throw Boom.internal(`job_origin=${job_origin} non supporté`) } diff --git a/server/src/services/etablissement.service.ts b/server/src/services/etablissement.service.ts index 4b626c5242..503c7b415e 100644 --- a/server/src/services/etablissement.service.ts +++ b/server/src/services/etablissement.service.ts @@ -8,13 +8,15 @@ import { EDiffusibleStatus } from "shared/constants/diffusibleStatus" import { BusinessErrorCodes } from "shared/constants/errorCodes" import { ETAT_UTILISATEUR } from "shared/constants/recruteur" import { AccessEntityType, AccessStatus } from "shared/models/roleManagement.model" +import { IUser2 } from "shared/models/user2.model" import { getLastStatusEvent } from "shared/utils/getLastStatusEvent" import { FCGetOpcoInfos } from "@/common/franceCompetencesClient" import { getStaticFilePath } from "@/common/utils/getStaticFilePath" import { getHttpClient } from "@/common/utils/httpUtils" +import { user2ToUserForToken } from "@/security/accessTokenService" -import { Cfa, Etablissement, LbaCompany, LbaCompanyLegacy, ReferentielOpco, RoleManagement, SiretDiffusibleStatus, UnsubscribeOF } from "../common/model/index" +import { Cfa, Etablissement, LbaCompany, LbaCompanyLegacy, ReferentielOpco, RoleManagement, SiretDiffusibleStatus, UnsubscribeOF, User2 } from "../common/model/index" import { isEmailFromPrivateCompany, isEmailSameDomain } from "../common/utils/mailUtils" import { sentryCaptureException } from "../common/utils/sentryUtils" import config from "../config" @@ -839,8 +841,8 @@ export const entrepriseOnboardingWorkflow = { }, } -export const sendUserConfirmationEmail = async (user: IUserRecruteur) => { - const url = createValidationMagicLink(user) +export const sendUserConfirmationEmail = async (user: IUser2) => { + const url = createValidationMagicLink(user2ToUserForToken(user)) await mailer.sendEmail({ to: user.email, subject: "Confirmez votre adresse mail", @@ -889,7 +891,11 @@ export const sendEmailConfirmationEntreprise = async (user: IUserRecruteur, recr }, }) } else { - await sendUserConfirmationEmail(user) + const user2 = await User2.findOne({ _id: user._id.toString() }).lean() + if (!user2) { + throw Boom.internal(`could not find user with id=${user._id}`) + } + await sendUserConfirmationEmail(user2) } } diff --git a/server/src/services/formulaire.service.ts b/server/src/services/formulaire.service.ts index a7bbc259c9..2eb4e0e7d7 100644 --- a/server/src/services/formulaire.service.ts +++ b/server/src/services/formulaire.service.ts @@ -4,11 +4,14 @@ import pkg from "mongodb" import type { FilterQuery, ModelUpdateOptions, UpdateQuery } from "mongoose" import { IDelegation, IJob, IJobWritable, IRecruiter, IUserRecruteur, JOB_STATUS } from "shared" import { ETAT_UTILISATEUR, RECRUITER_STATUS } from "shared/constants/recruteur" +import { AccessEntityType, AccessStatus } from "shared/models/roleManagement.model" +import { IUser2 } from "shared/models/user2.model" +import { getLastStatusEvent } from "shared/utils/getLastStatusEvent" import { db } from "@/common/mongodb" import { getStaticFilePath } from "@/common/utils/getStaticFilePath" -import { Cfa, Recruiter, UnsubscribeOF } from "../common/model/index" +import { Cfa, Entreprise, Recruiter, RoleManagement, UnsubscribeOF } from "../common/model/index" import { asyncForEach } from "../common/utils/asyncUtils" import config from "../config" @@ -19,7 +22,7 @@ import dayjs from "./dayjs.service" import { sendEmailConfirmationEntreprise } from "./etablissement.service" import mailer, { sanitizeForEmail } from "./mailer.service" import { getRomeDetailsFromDB } from "./rome.service" -import { getUser, getUserStatus } from "./userRecruteur.service" +import { getUserRecruteurByRecruiter, getUserStatus } from "./userRecruteur.service" const { ObjectId } = pkg @@ -89,12 +92,16 @@ export const getFormulaires = async (query: FilterQuery, select: obj */ export const createJob = async ({ job, id }: { job: IJobWritable; id: string }): Promise => { // get user data - const user = await getUser({ establishment_id: id }) - const userStatus: ETAT_UTILISATEUR | null = (user ? getUserStatus(user.status) : null) ?? null + const recruiter = await Recruiter.findOne({ establishment_id: id }).lean() + if (!recruiter) { + throw Boom.internal(`recruiter with establishment_id=${id} not found`) + } + const userRecruteur = await getUserRecruteurByRecruiter(recruiter) + const userStatus: ETAT_UTILISATEUR | null = (userRecruteur ? getUserStatus(userRecruteur.status) : null) ?? null const isUserAwaiting = userStatus !== ETAT_UTILISATEUR.VALIDE const jobPartial: Partial = job - jobPartial.job_status = user && isUserAwaiting ? JOB_STATUS.EN_ATTENTE : JOB_STATUS.ACTIVE + jobPartial.job_status = userRecruteur && isUserAwaiting ? JOB_STATUS.EN_ATTENTE : JOB_STATUS.ACTIVE // get user activation state if not managed by a CFA const codeRome = job.rome_code[0] const romeData = await getRomeDetailsFromDB(codeRome) @@ -118,18 +125,18 @@ export const createJob = async ({ job, id }: { job: IJobWritable; id: string }): throw Boom.internal("unexpected: no job found after job creation") } // if first offer creation for an Entreprise, send specific mail - if (jobs.length === 1 && is_delegated === false && user) { - await sendEmailConfirmationEntreprise(user, updatedFormulaire) + if (jobs.length === 1 && is_delegated === false && userRecruteur) { + await sendEmailConfirmationEntreprise(userRecruteur, updatedFormulaire) return updatedFormulaire } - let contactCFA: IUserRecruteur | null = null + let contactCFA: IUser2 | null = null if (is_delegated) { if (!cfa_delegated_siret) { throw Boom.internal(`unexpected: could not find user recruteur CFA that created the job`) } // get CFA informations if formulaire is handled by a CFA - contactCFA = await getUser({ establishment_siret: cfa_delegated_siret }) + contactCFA = await getUser2ManagingOffer(createdJob) if (!contactCFA) { throw Boom.internal(`unexpected: could not find user recruteur CFA that created the job`) } @@ -142,29 +149,22 @@ export const createJob = async ({ job, id }: { job: IJobWritable; id: string }): * Create job delegations */ export const createJobDelegations = async ({ jobId, etablissementCatalogueIds }: { jobId: IJob["_id"] | string; etablissementCatalogueIds: string[] }): Promise => { - const offreDocument = await getOffre(jobId) - if (!offreDocument) { + const recruiter = await getOffre(jobId) + if (!recruiter) { throw Boom.internal("Offre not found", { jobId, etablissementCatalogueIds }) } - const userDocument = await getUser({ establishment_id: offreDocument.establishment_id }) - if (!userDocument) { - throw Boom.internal("User not found", { jobId, etablissementCatalogueIds }) - } - if (!userDocument.status) { - throw Boom.internal("User is missing status object", { jobId, etablissementCatalogueIds }) - } - const userState = userDocument.status.pop() - - const offre = offreDocument.jobs.find((job) => job._id.toString() === jobId.toString()) - - if (!offre) { - throw Boom.internal("Offre not found", { jobId, etablissementCatalogueIds }) + const offre = getJobFromRecruiter(recruiter, jobId.toString()) + const managingUser = await getUser2ManagingOffer(offre) + const entreprise = await Entreprise.findOne({ establishment_id: recruiter.establishment_id }).lean() + let shouldSentMailToCfa = false + if (entreprise) { + const role = await RoleManagement.findOne({ user_id: managingUser._id, authorized_id: entreprise._id.toString(), authorized_type: AccessEntityType.ENTREPRISE }).lean() + if (role && getLastStatusEvent(role.status)?.status === AccessStatus.GRANTED) { + shouldSentMailToCfa = true + } } - const { etablissements } = await getCatalogueEtablissements({ _id: { $in: etablissementCatalogueIds } }, { _id: 1 }) - const delegations: IDelegation[] = [] - const promises = etablissements.map(async (etablissement) => { const formations = await getCatalogueFormations( { @@ -191,8 +191,8 @@ export const createJobDelegations = async ({ jobId, etablissementCatalogueIds }: delegations.push({ siret_code, email }) - if (userState?.status === ETAT_UTILISATEUR.VALIDE) { - await sendDelegationMailToCFA(email, offre, offreDocument, siret_code) + if (shouldSentMailToCfa) { + await sendDelegationMailToCFA(email, offre, recruiter, siret_code) } }) @@ -564,7 +564,7 @@ export async function sendDelegationMailToCFA(email: string, offre: IJob, recrui }) } -export async function sendMailNouvelleOffre(recruiter: IRecruiter, job: IJob, contactCFA?: IUserRecruteur) { +export async function sendMailNouvelleOffre(recruiter: IRecruiter, job: IJob, contactCFA?: IUser2) { const isRecruteurAwaiting = recruiter.status === RECRUITER_STATUS.EN_ATTENTE_VALIDATION if (isRecruteurAwaiting) { return diff --git a/server/src/services/userRecruteur.service.ts b/server/src/services/userRecruteur.service.ts index 7440637f01..0192d37318 100644 --- a/server/src/services/userRecruteur.service.ts +++ b/server/src/services/userRecruteur.service.ts @@ -3,7 +3,7 @@ import { randomUUID } from "crypto" import Boom from "boom" import { ObjectId } from "mongodb" import type { FilterQuery, ModelUpdateOptions, UpdateQuery } from "mongoose" -import { IUserRecruteur, IUserRecruteurWritable, IUserStatusValidation, UserRecruteurForAdminProjection, assertUnreachable } from "shared" +import { IRecruiter, IUserRecruteur, IUserRecruteurWritable, IUserStatusValidation, UserRecruteurForAdminProjection, assertUnreachable } from "shared" import { CFA, ENTREPRISE, ETAT_UTILISATEUR, VALIDATION_UTILISATEUR } from "shared/constants/recruteur" import { EntrepriseStatus, IEntrepriseStatusEvent } from "shared/models/entreprise.model" import { AccessEntityType, AccessStatus, IRoleManagement } from "shared/models/roleManagement.model" @@ -26,13 +26,6 @@ import mailer, { sanitizeForEmail } from "./mailer.service" */ export const createApiKey = (): string => `mna-${randomUUID()}` -/** - * @description get a single user using a given query filter - */ -export const getUser = async (query: FilterQuery): Promise => { - return UserRecruteur.findOne(query).lean() -} - const entrepriseStatusEventToUserRecruteurStatusEvent = (entrepriseStatusEvent: IEntrepriseStatusEvent, forcedStatus: ETAT_UTILISATEUR): IUserStatusValidation => { const { date, reason, validation_type, granted_by } = entrepriseStatusEvent return { @@ -103,6 +96,28 @@ const roleStatusToUserRecruteurStatus = (roleStatus: AccessStatus): ETAT_UTILISA export const getUserRecruteurById = (id: string | ObjectId) => getUserRecruteurByUser2Query({ _id: typeof id === "string" ? new ObjectId(id) : id }) export const getUserRecruteurByEmail = (email: string) => getUserRecruteurByUser2Query({ email }) +export const getUserRecruteurByRecruiter = async (recruiter: IRecruiter): Promise => { + const { cfa_delegated_siret, establishment_id } = recruiter + if (cfa_delegated_siret) { + const cfa = await Cfa.findOne({ siret: cfa_delegated_siret }).lean() + if (!cfa) { + throw new Error(`cfa with cfa_delegated_siret=${cfa_delegated_siret} not found`) + } + const role = await RoleManagement.findOne({ authorized_type: AccessEntityType.CFA, authorized_id: cfa._id.toString() }).lean() + if (!role) { + throw new Error(`role with authorized_id=${cfa._id} not found`) + } + return getUserRecruteurById(role.user_id) + } else if (establishment_id) { + const entreprise = await Entreprise.findOne({ establishment_id }).lean() + if (!entreprise) { + throw new Error(`entreprise with establishment_id=${establishment_id} not found`) + } + return getUserRecruteurById(entreprise._id) + } else { + throw new Error("inattendu: pas de establishment_id ni de cfa_delegated_siret") + } +} const getUserRecruteurByUser2Query = async (user2query: Partial): Promise => { const user = await User2.findOne(user2query).lean() @@ -132,7 +147,7 @@ const getUserRecruteurByUser2Query = async (user2query: Partial): Promis ...organismeData, createdAt: organismeData?.createdAt ?? user.createdAt, updatedAt: organismeData?.updatedAt ?? user.updatedAt, - is_email_checked: user.status.some((event) => event.status === UserEventType.VALIDATION_EMAIL), + is_email_checked: isUserEmailChecked(user), type, _id, email, @@ -382,3 +397,5 @@ export const getUsersWithRoles = async () => { console.log(usersWithRoles.slice(0, 3)) return usersWithRoles } + +export const isUserEmailChecked = (user: IUser2): boolean => user.status.some((event) => event.status === UserEventType.VALIDATION_EMAIL) From d54098eff4ee1186bdef910d1349646cdcf7e92b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9my=20Auricoste?= Date: Tue, 27 Feb 2024 17:32:04 +0100 Subject: [PATCH 20/78] fix: renommage variable --- server/src/services/application.service.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/server/src/services/application.service.ts b/server/src/services/application.service.ts index 1f0ed57c2f..810afd0eec 100644 --- a/server/src/services/application.service.ts +++ b/server/src/services/application.service.ts @@ -310,15 +310,15 @@ const buildRecruiterEmailUrls = async (application: IApplication) => { const utmRecruiterData = "&utm_source=jecandidate&utm_medium=email&utm_campaign=jecandidaterecruteur" // get the related recruiters to fetch it's establishment_id - let userRecruteur: IUser2 | undefined + let user: IUser2 | undefined if (application.job_id) { const recruiter = await Recruiter.findOne({ "jobs._id": application.job_id }).lean() if (recruiter) { - userRecruteur = await getUser2ManagingOffer(getJobFromRecruiter(recruiter, application.job_id)) + user = await getUser2ManagingOffer(getJobFromRecruiter(recruiter, application.job_id)) } } - const userForToken = buildUserForToken(application, userRecruteur) + const userForToken = buildUserForToken(application, user) const urls = { meetCandidateUrl: buildReplyLink(application, ApplicantIntention.ENTRETIEN, userForToken), waitCandidateUrl: buildReplyLink(application, ApplicantIntention.NESAISPAS, userForToken), @@ -331,7 +331,7 @@ const buildRecruiterEmailUrls = async (application: IApplication) => { cancelJobUrl: "", } - if (application.job_id && userRecruteur) { + if (application.job_id && user) { urls.jobProvidedUrl = createProvidedJobLink(userForToken, application.job_id, utmRecruiterData) urls.cancelJobUrl = createCancelJobLink(userForToken, application.job_id, utmRecruiterData) } From f5a235e7aa3be073a4dfe817ddf15ae4a48204eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9my=20Auricoste?= Date: Thu, 29 Feb 2024 10:58:46 +0100 Subject: [PATCH 21/78] fix: refactor createUser + refactor relanceOpco --- .../etablissementRecruteur.controller.ts | 9 +- .../src/http/controllers/user.controller.ts | 19 +-- .../lba_recruteur/formulaire/createUser.ts | 17 ++- .../jobs/lba_recruteur/opco/relanceOpco.ts | 74 +++++----- server/src/services/etablissement.service.ts | 6 +- server/src/services/permissions.service.ts | 34 +++++ server/src/services/userRecruteur.service.ts | 132 ++++++++++++++---- 7 files changed, 206 insertions(+), 85 deletions(-) create mode 100644 server/src/services/permissions.service.ts diff --git a/server/src/http/controllers/etablissementRecruteur.controller.ts b/server/src/http/controllers/etablissementRecruteur.controller.ts index b68ff4549a..cb8174bb4d 100644 --- a/server/src/http/controllers/etablissementRecruteur.controller.ts +++ b/server/src/http/controllers/etablissementRecruteur.controller.ts @@ -1,5 +1,5 @@ import Boom from "boom" -import { IUserRecruteur, toPublicUser, zRoutes } from "shared" +import { toPublicUser, zRoutes } from "shared" import { BusinessErrorCodes } from "shared/constants/errorCodes" import { ETAT_UTILISATEUR, RECRUITER_STATUS } from "shared/constants/recruteur" @@ -178,7 +178,8 @@ export default (server: Server) => { const { contacts } = siretInfos // Creation de l'utilisateur en base de données - let newCfa: IUserRecruteur = await createUser({ ...req.body, ...siretInfos, is_email_checked: false }) + // eslint-disable-next-line prefer-const + let { userRecruteur: newCfa, user: userCfa } = await createUser({ ...req.body, ...siretInfos, is_email_checked: false }) const slackNotification = { subject: "RECRUTEUR", @@ -193,7 +194,7 @@ export default (server: Server) => { if (isUserMailExistInReferentiel(contacts, email)) { // Validation automatique de l'utilisateur newCfa = await autoValidateUser(newCfa._id) - await sendUserConfirmationEmail(newCfa) + await sendUserConfirmationEmail(userCfa) // Keep the same structure as ENTREPRISE return res.status(200).send({ user: newCfa }) } @@ -203,7 +204,7 @@ export default (server: Server) => { if (userEmailDomain && domains.includes(userEmailDomain)) { // Validation automatique de l'utilisateur newCfa = await autoValidateUser(newCfa._id) - await sendUserConfirmationEmail(newCfa) + await sendUserConfirmationEmail(userCfa) // Keep the same structure as ENTREPRISE return res.status(200).send({ user: newCfa }) } diff --git a/server/src/http/controllers/user.controller.ts b/server/src/http/controllers/user.controller.ts index 9758be2f0c..bc4ffa80b4 100644 --- a/server/src/http/controllers/user.controller.ts +++ b/server/src/http/controllers/user.controller.ts @@ -1,6 +1,7 @@ import Boom from "boom" import { CFA, ETAT_UTILISATEUR, VALIDATION_UTILISATEUR } from "shared/constants/recruteur" import { IJob, getUserStatus, zRoutes } from "shared/index" +import { AccessStatus } from "shared/models/roleManagement.model" import { stopSession } from "@/common/utils/session.service" import { getUserFromRequest } from "@/security/authenticationService" @@ -102,18 +103,18 @@ export default (server: Server) => { onRequest: [server.auth(zRoutes.post["/admin/users"])], }, async (req, res) => { - const user = await createUser({ + const { userRecruteur } = await createUser({ ...req.body, is_email_checked: true, - status: [ - { - status: ETAT_UTILISATEUR.ATTENTE, - validation_type: VALIDATION_UTILISATEUR.MANUAL, - user: getUserFromRequest(req, zRoutes.post["/admin/users"]).value._id.toString(), - }, - ], + statusEvent: { + reason: "création par l'interface admin", + date: new Date(), + status: AccessStatus.AWAITING_VALIDATION, + validation_type: VALIDATION_UTILISATEUR.MANUAL, + granted_by: getUserFromRequest(req, zRoutes.post["/admin/users"]).value._id.toString(), + }, }) - return res.status(200).send(user) + return res.status(200).send(userRecruteur) } ) diff --git a/server/src/jobs/lba_recruteur/formulaire/createUser.ts b/server/src/jobs/lba_recruteur/formulaire/createUser.ts index 15c6d2a4b9..e15312c4bf 100644 --- a/server/src/jobs/lba_recruteur/formulaire/createUser.ts +++ b/server/src/jobs/lba_recruteur/formulaire/createUser.ts @@ -1,5 +1,6 @@ -import { ETAT_UTILISATEUR, VALIDATION_UTILISATEUR } from "shared/constants/recruteur" +import { VALIDATION_UTILISATEUR } from "shared/constants/recruteur" import { IUserRecruteur } from "shared/models" +import { AccessStatus } from "shared/models/roleManagement.model" import { logger } from "../../../common/logger" import { createUser, getUserRecruteurByEmail } from "../../../services/userRecruteur.service" @@ -36,14 +37,12 @@ export const createUserFromCLI = async ( scope, type: Type, is_email_checked: Email_valide, - status: [ - { - status: ETAT_UTILISATEUR.VALIDE, - validation_type: VALIDATION_UTILISATEUR.AUTO, - user: "SERVEUR", - date: new Date(), - }, - ], + statusEvent: { + reason: "created from CLI", + status: AccessStatus.GRANTED, + validation_type: VALIDATION_UTILISATEUR.AUTO, + date: new Date(), + }, }) logger.info(`User created : ${email} — ${scope} - admin: ${Type === "ADMIN"}`) diff --git a/server/src/jobs/lba_recruteur/opco/relanceOpco.ts b/server/src/jobs/lba_recruteur/opco/relanceOpco.ts index fd4a42ca16..23ef2bb574 100644 --- a/server/src/jobs/lba_recruteur/opco/relanceOpco.ts +++ b/server/src/jobs/lba_recruteur/opco/relanceOpco.ts @@ -1,9 +1,10 @@ -import { IUserRecruteur } from "shared" -import { ETAT_UTILISATEUR } from "shared/constants/recruteur" +import { OPCOS } from "shared/constants/recruteur" +import { AccessEntityType, AccessStatus } from "shared/models/roleManagement.model" +import { isEnum } from "@/common/utils/enumUtils" import { getStaticFilePath } from "@/common/utils/getStaticFilePath" -import { UserRecruteur } from "../../../common/model/index" +import { Entreprise, RoleManagement, User2 } from "../../../common/model/index" import { asyncForEach } from "../../../common/utils/asyncUtils" import config from "../../../config" import mailer from "../../../services/mailer.service" @@ -13,42 +14,49 @@ import mailer from "../../../services/mailer.service" * @returns {} */ export const relanceOpco = async () => { - const userAwaitingValidation = await UserRecruteur.find({ - $expr: { $eq: [{ $arrayElemAt: ["$status.status", -1] }, ETAT_UTILISATEUR.ATTENTE] }, - opco: { $nin: [null, "Opco multiple", "inconnu"] }, - }).lean() + const rolesAwaitingValidation = await RoleManagement.find( + { + $expr: { $eq: [{ $arrayElemAt: ["$status.status", -1] }, AccessStatus.AWAITING_VALIDATION] }, + authorized_type: AccessEntityType.ENTREPRISE, + }, + { authorized_id: 1 } + ).lean() // Cancel the job if there's no users awaiting validation - if (!userAwaitingValidation.length) return + if (!rolesAwaitingValidation.length) return - // count user to validate per opco - const userList = userAwaitingValidation.reduce>((acc, user) => { - if (user.opco) { - if (user.opco in acc) { - acc[user.opco]++ - } else { - acc[user.opco] = 1 + const entreprises = await Entreprise.find({ _id: { $in: rolesAwaitingValidation.map(({ authorized_id }) => authorized_id) } }) + const opcoCounts = entreprises.reduce>( + (acc, entreprise) => { + const { opco } = entreprise + if (!isEnum(OPCOS, opco)) { + return acc } - } - return acc - }, {}) + const oldCount = acc[opco] ?? 0 + acc[opco] = oldCount + 1 + return acc + }, + {} as Record + ) + await Promise.all( + Object.entries(opcoCounts).map(async ([opco, count]) => { + // Get related user to send the email + const roles = await RoleManagement.find({ authorized_type: AccessEntityType.OPCO, authorized_id: opco }).lean() + const users = await User2.find({ _id: { $in: roles.map((role) => role.user_id) } }) - for (const opco in userList) { - // Get related user to send the email - const users = await UserRecruteur.find({ scope: opco, type: "OPCO" }) - - await asyncForEach(users, async (user: IUserRecruteur) => { - await mailer.sendEmail({ - to: user.email, - subject: "Nouveaux comptes entreprises à valider", - template: getStaticFilePath("./templates/mail-relance-opco.mjml.ejs"), - data: { - images: { - logoLba: `${config.publicUrl}/images/emails/logo_LBA.png?raw=true`, + await asyncForEach(users, async (user) => { + await mailer.sendEmail({ + to: user.email, + subject: "Nouveaux comptes entreprises à valider", + template: getStaticFilePath("./templates/mail-relance-opco.mjml.ejs"), + data: { + images: { + logoLba: `${config.publicUrl}/images/emails/logo_LBA.png?raw=true`, + }, + count, }, - count: userList[opco], - }, + }) }) }) - } + ) } diff --git a/server/src/services/etablissement.service.ts b/server/src/services/etablissement.service.ts index 503c7b415e..3f5bef0e18 100644 --- a/server/src/services/etablissement.service.ts +++ b/server/src/services/etablissement.service.ts @@ -558,8 +558,8 @@ export const autoValidateCompany = async (userRecruteur: IUserRecruteur) => { return { userRecruteur, validated } } -export const isCompanyValid = async (userRecruteur: IUserRecruteur) => { - const { establishment_siret: siret, email } = userRecruteur +export const isCompanyValid = async (props: { establishment_siret?: string | null; email: string }): Promise => { + const { establishment_siret: siret, email } = props if (!siret) { return false } @@ -775,7 +775,7 @@ export const entrepriseOnboardingWorkflow = { cfa_delegated_siret, }) const formulaireId = formulaireInfo.establishment_id - let newEntreprise: IUserRecruteur = await createUser({ ...savedData, establishment_id: formulaireId, type: ENTREPRISE, is_email_checked: false, is_qualiopi: false }) + let { userRecruteur: newEntreprise } = await createUser({ ...savedData, establishment_id: formulaireId, type: ENTREPRISE, is_email_checked: false, is_qualiopi: false }) if (hasSiretError) { newEntreprise = await setUserInError(newEntreprise._id, "Erreur lors de l'appel à l'API SIRET") diff --git a/server/src/services/permissions.service.ts b/server/src/services/permissions.service.ts new file mode 100644 index 0000000000..de5ceb5d57 --- /dev/null +++ b/server/src/services/permissions.service.ts @@ -0,0 +1,34 @@ +import Boom from "boom" +import { IRoleManagement, IRoleManagementEvent } from "shared/models/roleManagement.model" +import { getLastStatusEvent } from "shared/utils/getLastStatusEvent" + +import { RoleManagement } from "@/common/model" + +export const modifyPermissionToUser = async ( + props: Pick, + eventProps: Pick +): Promise => { + const event: IRoleManagementEvent = { + ...eventProps, + date: new Date(), + } + const role = await RoleManagement.findOne(props).lean() + if (role) { + const lastEvent = getLastStatusEvent(role.status) + if (lastEvent?.status === eventProps.status) { + return role + } + const newRole = await RoleManagement.findOneAndUpdate({ _id: role._id }, { $push: { status: event } }, { new: true }).lean() + if (!newRole) { + throw Boom.internal("inattendu") + } + return newRole + } else { + const newRole: Omit = { + ...props, + status: [event], + } + const role = await RoleManagement.create(newRole) + return role + } +} diff --git a/server/src/services/userRecruteur.service.ts b/server/src/services/userRecruteur.service.ts index 0192d37318..f5ac728ff7 100644 --- a/server/src/services/userRecruteur.service.ts +++ b/server/src/services/userRecruteur.service.ts @@ -4,21 +4,23 @@ import Boom from "boom" import { ObjectId } from "mongodb" import type { FilterQuery, ModelUpdateOptions, UpdateQuery } from "mongoose" import { IRecruiter, IUserRecruteur, IUserRecruteurWritable, IUserStatusValidation, UserRecruteurForAdminProjection, assertUnreachable } from "shared" -import { CFA, ENTREPRISE, ETAT_UTILISATEUR, VALIDATION_UTILISATEUR } from "shared/constants/recruteur" +import { CFA, ENTREPRISE, ETAT_UTILISATEUR, OPCOS, VALIDATION_UTILISATEUR } from "shared/constants/recruteur" import { EntrepriseStatus, IEntrepriseStatusEvent } from "shared/models/entreprise.model" -import { AccessEntityType, AccessStatus, IRoleManagement } from "shared/models/roleManagement.model" +import { AccessEntityType, AccessStatus, IRoleManagement, IRoleManagementEvent } from "shared/models/roleManagement.model" import { IUser2, IUserStatusEvent, UserEventType } from "shared/models/user2.model" import { getLastStatusEvent } from "shared/utils/getLastStatusEvent" import { entriesToTypedRecord, typedKeys } from "shared/utils/objectUtils" +import { parseEnumOrError } from "@/common/utils/enumUtils" import { getStaticFilePath } from "@/common/utils/getStaticFilePath" import { Cfa, Entreprise, RoleManagement, User2, UserRecruteur } from "../common/model/index" import config from "../config" import { createAuthMagicLink } from "./appLinks.service" -import { ADMIN } from "./constant.service" +import { ADMIN, OPCO } from "./constant.service" import mailer, { sanitizeForEmail } from "./mailer.service" +import { modifyPermissionToUser } from "./permissions.service" /** * @description generate an API key @@ -163,34 +165,110 @@ const getUserRecruteurByUser2Query = async (user2query: Partial): Promis * @description création d'un nouveau user recruteur. Le champ status peut être passé ou, s'il n'est pas passé, être sauvé ultérieurement */ export const createUser = async ( - userRecruteurProps: Omit & Partial> -): Promise => { - let scope = userRecruteurProps.scope ?? undefined - + userRecruteurProps: Omit & { statusEvent?: IRoleManagementEvent } +): Promise<{ userRecruteur: IUserRecruteur; user: IUser2 }> => { + const { + first_name, + is_email_checked, + last_name, + type, + address, + address_detail, + establishment_enseigne, + establishment_id, + establishment_raison_sociale, + establishment_siret, + geo_coordinates, + idcc, + last_connection, + opco, + origin, + phone, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + scope, + statusEvent, + } = userRecruteurProps const formatedEmail = userRecruteurProps.email.toLocaleLowerCase() - if (!scope) { - if (userRecruteurProps.type === "CFA") { - // generate user scope - const [key] = randomUUID().split("-") - scope = `cfa-${key}` - } else { - let key - if (userRecruteurProps?.establishment_raison_sociale) { - key = userRecruteurProps.establishment_raison_sociale.toLowerCase().replace(/ /g, "-") - } else { - key = randomUUID().split("-")[0] - } - scope = `etp-${key}` + let user = await User2.findOne({ email: formatedEmail }).lean() + if (!user) { + const status: IUserStatusEvent[] = [] + if (is_email_checked) { + status.push({ + date: new Date(), + reason: "creation", + status: UserEventType.VALIDATION_EMAIL, + validation_type: VALIDATION_UTILISATEUR.MANUAL, + }) + } + status.push({ + date: new Date(), + reason: "creation", + status: UserEventType.ACTIF, + validation_type: VALIDATION_UTILISATEUR.MANUAL, + }) + const userFields: Omit = { + email: formatedEmail, + first_name, + last_name, + phone: phone ?? "", + last_action_date: last_connection ?? new Date(), + origin, + status, } + user = await User2.create(userFields) } - const createdUser = await UserRecruteur.create({ - status: [], - ...userRecruteurProps, - scope, - email: formatedEmail, - }) - return createdUser.toObject() + let addedRole: Parameters[0] | null = null + if (type === ENTREPRISE || type === CFA) { + if (!establishment_siret) { + throw Boom.internal("siret is missing") + } + let entreprise = await Entreprise.findOne({ siret: establishment_siret }).lean() + if (!entreprise) { + entreprise = await Entreprise.create({ + siret: establishment_siret, + address, + address_detail, + enseigne: establishment_enseigne, + raison_sociale: establishment_raison_sociale, + establishment_id, + origin, + opco, + idcc, + geo_coordinates, + }) + } + addedRole = { + user_id: user._id, + authorized_id: entreprise._id.toString(), + authorized_type: AccessEntityType.ENTREPRISE, + origin: origin ?? "createUser", + } + } else if (type === ADMIN) { + addedRole = { + user_id: user._id, + authorized_id: "", + authorized_type: AccessEntityType.ADMIN, + origin: origin ?? "createUser", + } + } else if (type === OPCO) { + addedRole = { + user_id: user._id, + authorized_id: parseEnumOrError(OPCOS, opco ?? null), + authorized_type: AccessEntityType.OPCO, + origin: origin ?? "createUser", + } + } else { + assertUnreachable(type) + } + if (statusEvent && addedRole) { + await modifyPermissionToUser(addedRole, statusEvent) + } + const userRecruteur = await getUserRecruteurById(user._id) + if (!userRecruteur) { + throw Boom.internal("unexpected") + } + return { userRecruteur, user } } /** From 64266d9d08c8c0ef2d541051d7c93dbf1f5b5511 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9my=20Auricoste?= Date: Tue, 5 Mar 2024 16:08:02 +0100 Subject: [PATCH 22/78] fix: suppression de updateMissingActivationState --- server/src/commands.ts | 6 -- server/src/jobs/jobs.ts | 3 - .../user/misc/updateMissingActivationState.ts | 66 ------------------- 3 files changed, 75 deletions(-) delete mode 100644 server/src/jobs/lba_recruteur/user/misc/updateMissingActivationState.ts diff --git a/server/src/commands.ts b/server/src/commands.ts index 3d6ba6d8dc..5832d822a4 100644 --- a/server/src/commands.ts +++ b/server/src/commands.ts @@ -318,12 +318,6 @@ program .option("-q, --queued", "Run job asynchronously", false) .action(createJobAction("pe:offre:export")) -program - .command("validate-user") - .description("Contrôle de validation des entreprises en attente de validation") - .option("-q, --queued", "Run job asynchronously", false) - .action(createJobAction("user:validate")) - program .command("update-siret-infos-in-error") .description("Remplis les données venant du SIRET pour les utilisateurs ayant eu une erreur pendant l'inscription") diff --git a/server/src/jobs/jobs.ts b/server/src/jobs/jobs.ts index c79fee2264..1450fbe2de 100644 --- a/server/src/jobs/jobs.ts +++ b/server/src/jobs/jobs.ts @@ -39,7 +39,6 @@ import { relanceFormulaire } from "./lba_recruteur/formulaire/relanceFormulaire" import { importReferentielOpcoFromConstructys } from "./lba_recruteur/opco/constructys/constructysImporter" import { relanceOpco } from "./lba_recruteur/opco/relanceOpco" import { createOffreCollection } from "./lba_recruteur/seed/createOffre" -import { checkAwaitingCompaniesValidation } from "./lba_recruteur/user/misc/updateMissingActivationState" import { updateSiretInfosInError } from "./lba_recruteur/user/misc/updateSiretInfosInError" import buildSAVE from "./lbb/buildSAVE" import updateGeoLocations from "./lbb/updateGeoLocations" @@ -299,8 +298,6 @@ export async function runJob(job: IInternalJobsCronTask | IInternalJobsSimple): return relanceOpco() case "pe:offre:export": return exportPE() - case "user:validate": - return checkAwaitingCompaniesValidation() case "siret:inError:update": return updateSiretInfosInError() case "etablissement:formations:activate:opt-out": diff --git a/server/src/jobs/lba_recruteur/user/misc/updateMissingActivationState.ts b/server/src/jobs/lba_recruteur/user/misc/updateMissingActivationState.ts deleted file mode 100644 index 4a369ee067..0000000000 --- a/server/src/jobs/lba_recruteur/user/misc/updateMissingActivationState.ts +++ /dev/null @@ -1,66 +0,0 @@ -import Boom from "boom" -import { ETAT_UTILISATEUR } from "shared/constants/recruteur" - -import { logger } from "../../../../common/logger" -import { UserRecruteur } from "../../../../common/model/index" -import { asyncForEach } from "../../../../common/utils/asyncUtils" -import { notifyToSlack } from "../../../../common/utils/slackUtils" -import { ENTREPRISE } from "../../../../services/constant.service" -import { autoValidateCompany } from "../../../../services/etablissement.service" -import { activateEntrepriseRecruiterForTheFirstTime, getFormulaire } from "../../../../services/formulaire.service" -import { sendWelcomeEmailToUserRecruteur } from "../../../../services/userRecruteur.service" - -export const checkAwaitingCompaniesValidation = async () => { - logger.info(`Start update missing validation state for companies...`) - const stat = { validated: 0, notFound: 0, total: 0 } - - const entreprises = await UserRecruteur.find({ - $expr: { $eq: [{ $arrayElemAt: ["$status.status", -1] }, ETAT_UTILISATEUR.ATTENTE] }, - type: ENTREPRISE, - }) - - if (!entreprises.length) { - await notifyToSlack({ subject: "USER VALIDATION", message: "Aucunes entreprises à contrôler" }) - return - } - - stat.total = entreprises.length - - logger.info(`${entreprises.length} etp à mettre à jour...`) - - await asyncForEach(entreprises, async (entreprise) => { - const { establishment_id } = entreprise - if (!establishment_id) { - throw Boom.internal("unexpected: no establishment_id for userRecruteur of type ENTREPRISE", { userId: entreprise._id }) - } - const userFormulaire = await getFormulaire({ establishment_id }) - - if (!userFormulaire) { - await UserRecruteur.findByIdAndDelete(entreprise.establishment_id) - return - } - - const { validated: hasBeenValidated } = await autoValidateCompany(entreprise) - if (hasBeenValidated) { - stat.validated++ - } else { - stat.notFound++ - } - - const firstJob = userFormulaire.jobs.at(0) - if (hasBeenValidated && firstJob) { - await activateEntrepriseRecruiterForTheFirstTime(userFormulaire) - - // Validate user email addresse - await sendWelcomeEmailToUserRecruteur(entreprise) - } - }) - - await notifyToSlack({ - subject: "USER VALIDATION", - message: `${stat.validated} entreprises validées sur un total de ${stat.total} (${stat.notFound} reste à valider manuellement)`, - }) - - logger.info(`Done.`) - return stat -} From 3c4b5b0ed1f56df93b1ea248d7667c5e09bd5ad3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9my=20Auricoste?= Date: Tue, 5 Mar 2024 16:09:48 +0100 Subject: [PATCH 23/78] fix: refactor user recruteur --- .../etablissementRecruteur.controller.ts | 73 ++-- .../http/controllers/formulaire.controller.ts | 12 + .../src/http/controllers/login.controller.ts | 47 ++- .../src/http/controllers/user.controller.ts | 101 +++-- server/src/http/sentry.ts | 2 - .../lba_recruteur/formulaire/createUser.ts | 31 +- .../user/misc/updateSiretInfosInError.ts | 68 ++-- server/src/security/accessTokenService.ts | 12 +- server/src/security/authenticationService.ts | 17 +- server/src/security/authorisationService.ts | 5 +- server/src/services/appLinks.service.ts | 14 +- server/src/services/etablissement.service.ts | 61 +-- server/src/services/formulaire.service.ts | 52 ++- server/src/services/login.service.ts | 48 ++- server/src/services/organization.service.ts | 55 +++ server/src/services/user.service.ts | 16 +- server/src/services/user2.service.ts | 41 ++ server/src/services/userRecruteur.service.ts | 368 ++++++++++-------- 18 files changed, 620 insertions(+), 403 deletions(-) create mode 100644 server/src/services/organization.service.ts create mode 100644 server/src/services/user2.service.ts diff --git a/server/src/http/controllers/etablissementRecruteur.controller.ts b/server/src/http/controllers/etablissementRecruteur.controller.ts index cb8174bb4d..c40bd0d17b 100644 --- a/server/src/http/controllers/etablissementRecruteur.controller.ts +++ b/server/src/http/controllers/etablissementRecruteur.controller.ts @@ -1,13 +1,17 @@ import Boom from "boom" -import { toPublicUser, zRoutes } from "shared" +import { assertUnreachable, toPublicUser, zRoutes } from "shared" import { BusinessErrorCodes } from "shared/constants/errorCodes" -import { ETAT_UTILISATEUR, RECRUITER_STATUS } from "shared/constants/recruteur" +import { RECRUITER_STATUS } from "shared/constants/recruteur" +import { UserEventType } from "shared/models/user2.model" +import { getLastStatusEvent } from "shared/utils/getLastStatusEvent" import { Cfa, Recruiter, UserRecruteur } from "@/common/model" import { startSession } from "@/common/utils/session.service" import config from "@/config" +import { user2ToUserForToken } from "@/security/accessTokenService" import { getUserFromRequest } from "@/security/authenticationService" import { generateDepotSimplifieToken } from "@/services/appLinks.service" +import { getUser2ByEmail } from "@/services/user2.service" import { getAllDomainsFromEmailList, getEmailDomain, isEmailFromPrivateCompany, isUserMailExistInReferentiel } from "../../common/utils/mailUtils" import { notifyToSlack } from "../../common/utils/slackUtils" @@ -24,9 +28,9 @@ import { } from "../../services/etablissement.service" import { autoValidateUser, - createUser, + createOrganizationUser, getUserRecruteurByEmail, - getUserStatus, + isUserEmailChecked, sendWelcomeEmailToUserRecruteur, setUserHasToBeManuallyValidated, updateLastConnectionDate, @@ -153,7 +157,8 @@ export default (server: Server) => { schema: zRoutes.post["/etablissement/creation"], }, async (req, res) => { - switch (req.body.type) { + const { type } = req.body + switch (type) { case ENTREPRISE: { const siret = req.body.establishment_siret const cfa_delegated_siret = req.body.cfa_delegated_siret ?? undefined @@ -162,8 +167,8 @@ export default (server: Server) => { if (result.errorCode === BusinessErrorCodes.ALREADY_EXISTS) throw Boom.forbidden(result.message, result) else throw Boom.badRequest(result.message, result) } - const token = generateDepotSimplifieToken(result.user) - return res.status(200).send({ ...result, token }) + const token = generateDepotSimplifieToken(user2ToUserForToken(result.user), result.formulaire.establishment_id) + return res.status(200).send({ formulaire: result.formulaire, user: result.user, token }) } case CFA: { const { email, establishment_siret } = req.body @@ -178,45 +183,45 @@ export default (server: Server) => { const { contacts } = siretInfos // Creation de l'utilisateur en base de données - // eslint-disable-next-line prefer-const - let { userRecruteur: newCfa, user: userCfa } = await createUser({ ...req.body, ...siretInfos, is_email_checked: false }) + const creationResult = await createOrganizationUser({ ...req.body, ...siretInfos, is_email_checked: false }) + const userCfa = creationResult.user const slackNotification = { subject: "RECRUTEUR", - message: `Nouvel OF en attente de validation - ${config.publicUrl}/espace-pro/administration/users/${newCfa._id}`, + message: `Nouvel OF en attente de validation - ${config.publicUrl}/espace-pro/administration/users/${userCfa._id}`, } if (!contacts.length) { // Validation manuelle de l'utilisateur à effectuer pas un administrateur - newCfa = await setUserHasToBeManuallyValidated(newCfa._id) + await setUserHasToBeManuallyValidated(creationResult) await notifyToSlack(slackNotification) - return res.status(200).send({ user: newCfa }) + return res.status(200).send({ user: userCfa }) } if (isUserMailExistInReferentiel(contacts, email)) { // Validation automatique de l'utilisateur - newCfa = await autoValidateUser(newCfa._id) + await autoValidateUser(creationResult) await sendUserConfirmationEmail(userCfa) // Keep the same structure as ENTREPRISE - return res.status(200).send({ user: newCfa }) + return res.status(200).send({ user: userCfa }) } if (isEmailFromPrivateCompany(formatedEmail)) { const domains = getAllDomainsFromEmailList(contacts.map(({ email }) => email)) const userEmailDomain = getEmailDomain(formatedEmail) if (userEmailDomain && domains.includes(userEmailDomain)) { // Validation automatique de l'utilisateur - newCfa = await autoValidateUser(newCfa._id) + await autoValidateUser(creationResult) await sendUserConfirmationEmail(userCfa) // Keep the same structure as ENTREPRISE - return res.status(200).send({ user: newCfa }) + return res.status(200).send({ user: userCfa }) } } // Validation manuelle de l'utilisateur à effectuer pas un administrateur - newCfa = await setUserHasToBeManuallyValidated(newCfa._id) + await setUserHasToBeManuallyValidated(creationResult) await notifyToSlack(slackNotification) // Keep the same structure as ENTREPRISE - return res.status(200).send({ user: newCfa }) + return res.status(200).send({ user: userCfa }) } default: { - throw Boom.badRequest("unsupported type") + assertUnreachable(type) } } } @@ -266,30 +271,24 @@ export default (server: Server) => { onRequest: [server.auth(zRoutes.post["/etablissement/validation"])], }, async (req, res) => { - const user = getUserFromRequest(req, zRoutes.post["/etablissement/validation"]).value - const email = user.identity.email.toLocaleLowerCase() + const userFromRequest = getUserFromRequest(req, zRoutes.post["/etablissement/validation"]).value + const email = userFromRequest.identity.email.toLocaleLowerCase() - const userRecruteur = await getUserRecruteurByEmail(email) - if (!userRecruteur) { + const user = await getUser2ByEmail(email) + if (!user) { throw Boom.badRequest("La validation de l'adresse mail a échoué. Merci de contacter le support La bonne alternance.") } - - const isUserAwaiting = getUserStatus(userRecruteur.status) === ETAT_UTILISATEUR.ATTENTE - - if (!isUserAwaiting) { - await sendWelcomeEmailToUserRecruteur(userRecruteur) + const userStatus = getLastStatusEvent(user.status)?.status + if (userStatus === UserEventType.DESACTIVE) { + throw Boom.forbidden("Votre compte est désactivé. Merci de contacter le support La bonne alternance.") } - - await updateLastConnectionDate(userRecruteur.email) - const connectedUser = await getUserRecruteurByEmail(userRecruteur.email) - - if (!connectedUser) { - throw Boom.forbidden() + if (!isUserEmailChecked(user)) { + await sendWelcomeEmailToUserRecruteur(user) } - await startSession(userRecruteur.email, res) - - return res.status(200).send(toPublicUser(connectedUser)) + await updateLastConnectionDate(email) + await startSession(email, res) + return res.status(200).send(toPublicUser(user)) } ) } diff --git a/server/src/http/controllers/formulaire.controller.ts b/server/src/http/controllers/formulaire.controller.ts index 610d2d4c99..04efd137e8 100644 --- a/server/src/http/controllers/formulaire.controller.ts +++ b/server/src/http/controllers/formulaire.controller.ts @@ -2,7 +2,9 @@ import Boom from "boom" import { zRoutes } from "shared/index" import { UserRecruteur } from "@/common/model" +import { getUserFromRequest } from "@/security/authenticationService" import { generateOffreToken } from "@/services/appLinks.service" +import { getUser2ByEmail } from "@/services/user2.service" import { getUserRecruteurById } from "@/services/userRecruteur.service" import { getApplicationsByJobId } from "../../services/application.service" @@ -202,6 +204,7 @@ export default (server: Server) => { }, async (req, res) => { const { establishment_id } = req.params + const user = getUserFromRequest(req, zRoutes.post["/formulaire/:establishment_id/offre"]).value const { is_disabled_elligible, job_type, @@ -220,6 +223,7 @@ export default (server: Server) => { if (!userRecruteur) { throw Boom.notFound() } + const updatedFormulaire = await createJob({ job: { is_disabled_elligible, @@ -236,6 +240,7 @@ export default (server: Server) => { rome_label, }, id: establishment_id, + user, }) const job = updatedFormulaire.jobs.at(0) if (!job) { @@ -258,6 +263,12 @@ export default (server: Server) => { }, async (req, res) => { const { establishment_id } = req.params + const tokenUser = getUserFromRequest(req, zRoutes.post["/formulaire/:establishment_id/offre/by-token"]).value + const { email } = tokenUser.identity + const user = await getUser2ByEmail(email) + if (!user) { + throw Boom.internal(`inattendu : impossible de récupérer l'utilisateur de type token ayant pour email=${email}`) + } const { is_disabled_elligible, job_type, @@ -292,6 +303,7 @@ export default (server: Server) => { rome_label, }, id: establishment_id, + user, }) const job = updatedFormulaire.jobs.at(0) if (!job) { diff --git a/server/src/http/controllers/login.controller.ts b/server/src/http/controllers/login.controller.ts index 5f047c40bf..6f0a27cd7d 100644 --- a/server/src/http/controllers/login.controller.ts +++ b/server/src/http/controllers/login.controller.ts @@ -2,16 +2,19 @@ import Boom from "boom" import { removeUrlsFromText } from "shared/helpers/common" import { toPublicUser, zRoutes } from "shared/index" +import { User2 } from "@/common/model" import { getStaticFilePath } from "@/common/utils/getStaticFilePath" +import { user2ToUserForToken } from "@/security/accessTokenService" import { getUserFromRequest } from "@/security/authenticationService" import { createAuthMagicLink } from "@/services/appLinks.service" +import { getUser2ByEmail } from "@/services/user2.service" import { startSession, stopSession } from "../../common/utils/session.service" import config from "../../config" import { sendUserConfirmationEmail } from "../../services/etablissement.service" import { controlUserState } from "../../services/login.service" import mailer, { sanitizeForEmail } from "../../services/mailer.service" -import { getUserRecruteurByEmail, getUserRecruteurById, updateLastConnectionDate } from "../../services/userRecruteur.service" +import { isUserEmailChecked, updateLastConnectionDate } from "../../services/userRecruteur.service" import { Server } from "../server" export default (server: Server) => { @@ -23,11 +26,11 @@ export default (server: Server) => { }, async (req, res) => { const { userId } = req.params - const user = await getUserRecruteurById(userId) + const user = await User2.findOne({ _id: userId }).lean() if (!user) { return res.status(400).send({ error: true, reason: "UNKNOWN" }) } - const { is_email_checked } = user + const is_email_checked = isUserEmailChecked(user) if (is_email_checked) { return res.status(400).send({ error: true, reason: "VERIFIED" }) } @@ -44,18 +47,14 @@ export default (server: Server) => { async (req, res) => { const { email } = req.body const formatedEmail = email.toLowerCase() - const user = await getUserRecruteurByEmail(formatedEmail) + const user = await User2.findOne({ email: formatedEmail }).lean() if (!user) { return res.status(400).send({ error: true, reason: "UNKNOWN" }) } - const { email: userEmail, first_name, last_name, is_email_checked } = user - - const userState = controlUserState(user.status) - if (userState?.error) { - return res.status(400).send(userState) - } + const is_email_checked = isUserEmailChecked(user) + const { email: userEmail, first_name, last_name } = user if (!is_email_checked) { await sendUserConfirmationEmail(user) @@ -65,6 +64,11 @@ export default (server: Server) => { }) } + const userState = await controlUserState(user) + if (userState?.error) { + return res.status(400).send(userState) + } + await mailer.sendEmail({ to: userEmail, subject: "Lien de connexion", @@ -75,7 +79,7 @@ export default (server: Server) => { }, last_name: sanitizeForEmail(removeUrlsFromText(last_name)), first_name: sanitizeForEmail(removeUrlsFromText(first_name)), - connexion_url: createAuthMagicLink(user), + connexion_url: createAuthMagicLink(user2ToUserForToken(user)), }, }) return res.status(200).send({}) @@ -89,31 +93,26 @@ export default (server: Server) => { onRequest: [server.auth(zRoutes.post["/login/verification"])], }, async (req, res) => { - const user = getUserFromRequest(req, zRoutes.post["/login/verification"]).value - const { email } = user.identity + const userFromRequest = getUserFromRequest(req, zRoutes.post["/login/verification"]).value + const { email } = userFromRequest.identity const formatedEmail = email.toLowerCase() - const userData = await getUserRecruteurByEmail(formatedEmail) + const user = await getUser2ByEmail(formatedEmail) - if (!userData) { + if (!user) { throw Boom.unauthorized() } - const userState = controlUserState(userData?.status) + const userState = await controlUserState(user) if (userState?.error) { throw Boom.forbidden() } await updateLastConnectionDate(formatedEmail) - const connectedUser = await getUserRecruteurByEmail(formatedEmail) - if (!connectedUser) { - throw Boom.forbidden() - } - await startSession(email, res) - return res.status(200).send(toPublicUser(connectedUser)) + return res.status(200).send(toPublicUser(user)) } ) @@ -130,8 +129,8 @@ export default (server: Server) => { if (!request.user) { throw Boom.forbidden() } - const user = getUserFromRequest(request, zRoutes.get["/auth/session"]).value - return response.status(200).send(toPublicUser(user)) + const userFromRequest = getUserFromRequest(request, zRoutes.get["/auth/session"]).value + return response.status(200).send(toPublicUser(userFromRequest)) } ) diff --git a/server/src/http/controllers/user.controller.ts b/server/src/http/controllers/user.controller.ts index bc4ffa80b4..b599e9adb9 100644 --- a/server/src/http/controllers/user.controller.ts +++ b/server/src/http/controllers/user.controller.ts @@ -1,18 +1,20 @@ import Boom from "boom" import { CFA, ETAT_UTILISATEUR, VALIDATION_UTILISATEUR } from "shared/constants/recruteur" import { IJob, getUserStatus, zRoutes } from "shared/index" -import { AccessStatus } from "shared/models/roleManagement.model" +import { IEntreprise } from "shared/models/entreprise.model" +import { AccessEntityType, AccessStatus } from "shared/models/roleManagement.model" +import { getLastStatusEvent } from "shared/utils/getLastStatusEvent" import { stopSession } from "@/common/utils/session.service" import { getUserFromRequest } from "@/security/authenticationService" -import { Recruiter, UserRecruteur } from "../../common/model/index" +import { Cfa, Entreprise, Recruiter, RoleManagement, UserRecruteur } from "../../common/model/index" import { getStaticFilePath } from "../../common/utils/getStaticFilePath" import config from "../../config" import { ENTREPRISE, RECRUITER_STATUS } from "../../services/constant.service" import { activateEntrepriseRecruiterForTheFirstTime, deleteFormulaire, getFormulaire, reactivateRecruiter } from "../../services/formulaire.service" import mailer, { sanitizeForEmail } from "../../services/mailer.service" -import { getUserAndRecruitersDataForOpcoUser, getValidatorIdentityFromStatus } from "../../services/user.service" +import { getUserAndRecruitersDataForOpcoUser, getUserNamesFromIds } from "../../services/user.service" import { createUser, getActiveUsers, @@ -26,6 +28,7 @@ import { updateUser, updateUser2Fields, updateUserValidationHistory, + userAndRoleAndOrganizationToUserRecruteur, validateUserEmail, } from "../../services/userRecruteur.service" import { Server } from "../server" @@ -103,18 +106,19 @@ export default (server: Server) => { onRequest: [server.auth(zRoutes.post["/admin/users"])], }, async (req, res) => { - const { userRecruteur } = await createUser({ - ...req.body, - is_email_checked: true, - statusEvent: { + const user = await createUser( + { + ...req.body, + is_email_checked: true, + }, + { reason: "création par l'interface admin", - date: new Date(), status: AccessStatus.AWAITING_VALIDATION, validation_type: VALIDATION_UTILISATEUR.MANUAL, granted_by: getUserFromRequest(req, zRoutes.post["/admin/users"]).value._id.toString(), - }, - }) - return res.status(200).send(userRecruteur) + } + ) + return res.status(200).send({ _id: user._id }) } ) @@ -163,37 +167,61 @@ export default (server: Server) => { ) server.get( - "/user/:userId", + "/user/:userId/organization/:organizationId", { - schema: zRoutes.get["/user/:userId"], - onRequest: [server.auth(zRoutes.get["/user/:userId"])], + schema: zRoutes.get["/user/:userId/organization/:organizationId"], + onRequest: [server.auth(zRoutes.get["/user/:userId/organization/:organizationId"])], }, async (req, res) => { - const user = await getUserRecruteurById(req.params.userId) - const loggedUser = getUserFromRequest(req, zRoutes.get["/user/:userId"]).value + const user = getUserFromRequest(req, zRoutes.get["/user/:userId/organization/:organizationId"]).value + if (!user) throw Boom.badRequest() + const { organizationId } = req.params + const role = await RoleManagement.findOne({ + user_id: user._id, + authorized_id: organizationId, + authorized_type: { $in: [AccessEntityType.ENTREPRISE, AccessEntityType.CFA] }, + $expr: { $eq: [{ $arrayElemAt: ["$status.status", -1] }, AccessStatus.GRANTED] }, + }).lean() + if (!role) { + throw Boom.forbidden() + } + const type = role.authorized_type === AccessEntityType.CFA ? CFA : ENTREPRISE + const organization = await (type === CFA ? Cfa : Entreprise).findOne({ _id: role.authorized_id }).lean() + if (!organization) { + throw Boom.internal(`inattendu : impossible de trouver l'organization avec id=${role.authorized_id}`) + } let jobs: IJob[] = [] - if (!user) throw Boom.badRequest() - - if (user.type === ENTREPRISE) { - const response = await Recruiter.findOne({ establishment_id: user.establishment_id as string }) - .select({ jobs: 1, _id: 0 }) - .lean() + if (type === ENTREPRISE) { + const { establishment_id } = organization as IEntreprise + if (!establishment_id) { + throw Boom.internal(`inattendu : establishment_id vide pour l'entreprise avec id=${role.authorized_id}`) + } + const response = await Recruiter.findOne({ establishment_id }).select({ jobs: 1, _id: 0 }).lean() if (!response) { throw Boom.internal("Get establishement from user failed to fetch", { userId: user._id }) } jobs = response.jobs } - // remove status data if not authorized to see it, else get identity - if ([ENTREPRISE, CFA].includes(loggedUser.type)) { - user.status = [] - } else { - user.status = await getValidatorIdentityFromStatus(user.status) + const userRecruteur = userAndRoleAndOrganizationToUserRecruteur(user, role, organization) + + const opcoOrAdminRole = await RoleManagement.findOne({ + user_id: user._id, + authorized_type: { $in: [AccessEntityType.ADMIN, AccessEntityType.OPCO] }, + $expr: { $eq: [{ $arrayElemAt: ["$status.status", -1] }, AccessStatus.GRANTED] }, + }).lean() + if (opcoOrAdminRole && getLastStatusEvent(opcoOrAdminRole.status)?.status === AccessStatus.GRANTED) { + const userIds = userRecruteur.status.flatMap(({ user }) => (user ? [user] : [])) + const users = await getUserNamesFromIds(userIds) + userRecruteur.status.forEach((event) => { + const user = users.find((user) => user._id.toString() === event.user) + if (!user) return + event.user = `${user.first_name} ${user.last_name}` + }) } - - return res.status(200).send({ ...user, jobs }) + return res.status(200).send({ ...userRecruteur, jobs }) } ) @@ -264,16 +292,17 @@ export default (server: Server) => { onRequest: [server.auth(zRoutes.put["/user/:userId/history"])], }, async (req, res) => { - const history = req.body + // TODO gestion couple user/organization + const newEvent = req.body const validator = getUserFromRequest(req, zRoutes.put["/user/:userId/history"]).value - const user = await updateUserValidationHistory(req.params.userId, { ...history, user: validator._id.toString() }) + const user = await updateUserValidationHistory(req.params.userId, { ...newEvent, user: validator._id.toString() }) if (!user) throw Boom.badRequest() const { email, last_name, first_name } = user // if user is disabled, return the user data directly - if (history.status === ETAT_UTILISATEUR.DESACTIVE) { + if (newEvent.status === ETAT_UTILISATEUR.DESACTIVE) { // send email to user to notify him his account has been disabled await mailer.sendEmail({ to: email, @@ -286,7 +315,7 @@ export default (server: Server) => { }, last_name: sanitizeForEmail(last_name), first_name: sanitizeForEmail(first_name), - reason: sanitizeForEmail(history.reason), + reason: sanitizeForEmail(newEvent.reason), emailSupport: "mailto:labonnealternance@apprentissage.beta.gouv.fr?subject=Compte%20pro%20non%20validé", }, }) @@ -296,8 +325,8 @@ export default (server: Server) => { /** * 20230831 kevin todo: share reason between front and back with shared folder */ - // if user isn't part of the OPCO, just send the user straigth back - if (history.reason === "Ne relève pas des champs de compétences de mon OPCO") { + // if user isn't part of the OPCO, just send the user straight back + if (newEvent.reason === "Ne relève pas des champs de compétences de mon OPCO") { return res.status(200).send(user) } @@ -317,7 +346,7 @@ export default (server: Server) => { if (userFormulaire.status === RECRUITER_STATUS.ARCHIVE) { // le recruiter étant archivé on se contente de le rendre de nouveau Actif await reactivateRecruiter(establishment_id) - } else { + } else if (userFormulaire.status === RECRUITER_STATUS.ACTIF) { // le compte se trouve validé, on procède à l'activation de la première offre et à la notification aux CFAs if (userFormulaire?.jobs?.length) { await activateEntrepriseRecruiterForTheFirstTime(userFormulaire) diff --git a/server/src/http/sentry.ts b/server/src/http/sentry.ts index 15463b79fc..1b0d4ef789 100644 --- a/server/src/http/sentry.ts +++ b/server/src/http/sentry.ts @@ -52,7 +52,6 @@ function extractUserData(request: FastifyRequest) { segment: "access-token", id: "_id" in identity ? identity._id.toString() : identity.email, email: identity.email, - type: identity.type, } } @@ -60,7 +59,6 @@ function extractUserData(request: FastifyRequest) { segment: "session", id: user.value._id.toString(), email: user.value.email, - type: user.value.type, } } diff --git a/server/src/jobs/lba_recruteur/formulaire/createUser.ts b/server/src/jobs/lba_recruteur/formulaire/createUser.ts index e15312c4bf..e01046e913 100644 --- a/server/src/jobs/lba_recruteur/formulaire/createUser.ts +++ b/server/src/jobs/lba_recruteur/formulaire/createUser.ts @@ -26,24 +26,25 @@ export const createUserFromCLI = async ( return } - await createUser({ - first_name, - last_name, - establishment_siret, - establishment_raison_sociale, - phone, - address, - email, - scope, - type: Type, - is_email_checked: Email_valide, - statusEvent: { + await createUser( + { + first_name, + last_name, + establishment_siret, + establishment_raison_sociale, + phone, + address, + email, + scope, + type: Type, + is_email_checked: Email_valide, + }, + { reason: "created from CLI", status: AccessStatus.GRANTED, validation_type: VALIDATION_UTILISATEUR.AUTO, - date: new Date(), - }, - }) + } + ) logger.info(`User created : ${email} — ${scope} - admin: ${Type === "ADMIN"}`) } diff --git a/server/src/jobs/lba_recruteur/user/misc/updateSiretInfosInError.ts b/server/src/jobs/lba_recruteur/user/misc/updateSiretInfosInError.ts index 0e2fde481c..950e59dfc2 100644 --- a/server/src/jobs/lba_recruteur/user/misc/updateSiretInfosInError.ts +++ b/server/src/jobs/lba_recruteur/user/misc/updateSiretInfosInError.ts @@ -1,60 +1,70 @@ import Boom from "boom" -import { JOB_STATUS, type IUserRecruteur } from "shared" -import { ETAT_UTILISATEUR, RECRUITER_STATUS } from "shared/constants/recruteur" +import { JOB_STATUS } from "shared" +import { RECRUITER_STATUS } from "shared/constants/recruteur" +import { EntrepriseStatus } from "shared/models/entreprise.model" import { AccessEntityType, AccessStatus } from "shared/models/roleManagement.model" import { getLastStatusEvent } from "shared/utils/getLastStatusEvent" import { getUser2ManagingOffer } from "@/services/application.service" import { logger } from "../../../../common/logger" -import { Cfa, Recruiter, RoleManagement, UserRecruteur } from "../../../../common/model/index" +import { Cfa, Entreprise, Recruiter, RoleManagement, User2 } from "../../../../common/model/index" import { asyncForEach } from "../../../../common/utils/asyncUtils" import { sentryCaptureException } from "../../../../common/utils/sentryUtils" import { notifyToSlack } from "../../../../common/utils/slackUtils" -import { CFA, ENTREPRISE } from "../../../../services/constant.service" +import { ENTREPRISE } from "../../../../services/constant.service" import { EntrepriseData, autoValidateCompany, getEntrepriseDataFromSiret, sendEmailConfirmationEntreprise } from "../../../../services/etablissement.service" import { activateEntrepriseRecruiterForTheFirstTime, archiveFormulaire, getFormulaire, sendMailNouvelleOffre, updateFormulaire } from "../../../../services/formulaire.service" -import { autoValidateUser, deactivateUser, setUserInError, updateUser } from "../../../../services/userRecruteur.service" +import { UserAndOrganization, deactivateUser, setEntrepriseInError } from "../../../../services/userRecruteur.service" -const updateUserRecruteursSiretInfosInError = async () => { - const userRecruteurs = await UserRecruteur.find({ - $expr: { $eq: [{ $arrayElemAt: ["$status.status", -1] }, ETAT_UTILISATEUR.ERROR] }, - $or: [{ type: CFA }, { type: ENTREPRISE }], +const updateEntreprisesInfosInError = async () => { + const entreprises = await Entreprise.find({ + $expr: { $eq: [{ $arrayElemAt: ["$status.status", -1] }, EntrepriseStatus.ERROR] }, }).lean() const stats = { success: 0, failure: 0, deactivated: 0 } - logger.info(`Correction des user recruteurs en erreur: ${userRecruteurs.length} user recruteurs à mettre à jour...`) - await asyncForEach(userRecruteurs, async (userRecruteur) => { - const { establishment_siret, _id, establishment_id, type } = userRecruteur + logger.info(`Correction des entreprises en erreur: ${entreprises.length} entreprises à mettre à jour...`) + await asyncForEach(entreprises, async (entreprise) => { + const { siret: siret, _id, establishment_id } = entreprise try { - if (!establishment_id || !establishment_siret) { - throw Boom.internal("unexpected: no establishment_id and/or establishment_siret for userRecruteur of type ENTREPRISE", { userId: userRecruteur._id }) + if (!establishment_id || !siret) { + throw Boom.internal("unexpected: no establishment_id and/or establishment_siret for userRecruteur of type ENTREPRISE", { id: entreprise._id }) } let recruteur = await getFormulaire({ establishment_id }) const { cfa_delegated_siret } = recruteur - const siretResponse = await getEntrepriseDataFromSiret({ siret: establishment_siret, cfa_delegated_siret: cfa_delegated_siret ?? undefined }) + const siretResponse = await getEntrepriseDataFromSiret({ siret, cfa_delegated_siret: cfa_delegated_siret ?? undefined }) if ("error" in siretResponse) { - logger.warn(`Correction des recruteurs en erreur: userRecruteur id=${_id}, désactivation car création interdite, raison=${siretResponse.message}`) + logger.warn(`Correction des recruteurs en erreur: entreprise id=${_id}, désactivation car création interdite, raison=${siretResponse.message}`) await deactivateUser(_id, siretResponse.message) stats.deactivated++ } else { const entrepriseData: Partial = siretResponse - let updatedUserRecruteur: IUserRecruteur = await updateUser({ _id }, entrepriseData) - recruteur = await updateFormulaire(recruteur.establishment_id, entrepriseData) - if (type === "ENTREPRISE") { - const result = await autoValidateCompany(updatedUserRecruteur) - updatedUserRecruteur = result.userRecruteur - if (result.validated) { - await activateEntrepriseRecruiterForTheFirstTime(recruteur) - await sendEmailConfirmationEntreprise(updatedUserRecruteur, recruteur) - } - } else { - updatedUserRecruteur = await autoValidateUser(userRecruteur._id) + const updatedEntreprise = await Entreprise.findOneAndUpdate({ _id }, entrepriseData, { new: true }).lean() + if (!updatedEntreprise) { + throw Boom.internal(`could not find and update entreprise with id=${_id}`) } + recruteur = await updateFormulaire(recruteur.establishment_id, entrepriseData) + const roles = await RoleManagement.find({ authorized_type: AccessEntityType.ENTREPRISE, authorized_id: updatedEntreprise._id.toString() }).lean() + const users = await User2.find({ _id: { $in: roles.map((role) => role.user_id) } }).lean() + await Promise.all( + users.map(async (user) => { + const userAndOrganization: UserAndOrganization = { user, type: ENTREPRISE, organization: updatedEntreprise } + const result = await autoValidateCompany(userAndOrganization) + if (result.validated) { + await activateEntrepriseRecruiterForTheFirstTime(recruteur) + const role = roles.find((role) => role.user_id.toString() === user._id.toString()) + const status = getLastStatusEvent(role?.status)?.status + if (!status) { + throw Boom.internal("inattendu : status du role non trouvé") + } + await sendEmailConfirmationEntreprise(user, recruteur, status, EntrepriseStatus.VALIDE) + } + }) + ) stats.success++ } } catch (err) { const errorMessage = (err && typeof err === "object" && "message" in err && err.message) || err - await setUserInError(userRecruteur._id, errorMessage + "") + await setEntrepriseInError(entreprise._id, errorMessage + "") logger.error(err) logger.error(`Correction des recruteurs en erreur: userRecruteur id=${_id}, erreur: ${errorMessage}`) sentryCaptureException(err) @@ -128,7 +138,7 @@ const updateRecruteursSiretInfosInError = async () => { } export const updateSiretInfosInError = async () => { - const userRecruteurResult = await updateUserRecruteursSiretInfosInError() + const userRecruteurResult = await updateEntreprisesInfosInError() const recruteurResult = await updateRecruteursSiretInfosInError() return { userRecruteurResult, diff --git a/server/src/security/accessTokenService.ts b/server/src/security/accessTokenService.ts index 9c70743c59..22adbd3cf5 100644 --- a/server/src/security/accessTokenService.ts +++ b/server/src/security/accessTokenService.ts @@ -8,11 +8,11 @@ import { assertUnreachable } from "shared/utils" import { Jsonify } from "type-fest" import { AnyZodObject, z } from "zod" +import { User2 } from "@/common/model" import { sentryCaptureException } from "@/common/utils/sentryUtils" import config from "@/config" import { controlUserState } from "../services/login.service" -import { getUserRecruteurById } from "../services/userRecruteur.service" // cf https://www.sistrix.com/ask-sistrix/technical-seo/site-structure/url-length-how-long-can-a-url-be const INTERNET_EXPLORER_V10_MAX_LENGTH = 2083 @@ -73,13 +73,15 @@ export type IAccessToken } | { type: "lba-company"; siret: string; email: string } | { type: "candidat"; email: string } - | { type: "IUser2"; email: string; _id: string } + | IUser2ForAccessToken scopes: ReadonlyArray> } +export type IUser2ForAccessToken = { type: "IUser2"; email: string; _id: string } + export type UserForAccessToken = IUserRecruteur | IAccessToken["identity"] -export const user2ToUserForToken = (user: IUser2): UserForAccessToken => ({ type: "IUser2", _id: user._id.toString(), email: user.email }) +export const user2ToUserForToken = (user: IUser2): IUser2ForAccessToken => ({ type: "IUser2", _id: user._id.toString(), email: user.email }) export function generateAccessToken(user: UserForAccessToken, scopes: ReadonlyArray>, options: { expiresIn?: string } = {}): string { const identity: IAccessToken["identity"] = "_id" in user ? { type: "IUserRecruteur", _id: user._id.toString(), email: user.email.toLowerCase() } : user @@ -196,11 +198,11 @@ export async function parseAccessToken( }) const token = data.payload as IAccessToken if (token.identity.type === "IUserRecruteur") { - const user = await getUserRecruteurById(token.identity._id) + const user = await User2.findOne({ _id: token.identity._id }).lean() if (!user) throw Boom.unauthorized() - const userStatus = controlUserState(user?.status) + const userStatus = await controlUserState(user) if (userStatus.error && !authorizedPaths.includes(schema.path)) { throw Boom.forbidden() diff --git a/server/src/security/authenticationService.ts b/server/src/security/authenticationService.ts index 50c4347163..a6306de1cd 100644 --- a/server/src/security/authenticationService.ts +++ b/server/src/security/authenticationService.ts @@ -4,20 +4,21 @@ import { FastifyRequest } from "fastify" import jwt, { JwtPayload } from "jsonwebtoken" import { ICredential, assertUnreachable } from "shared" import { PathParam, QueryString } from "shared/helpers/generateUri" -import { IUserRecruteur } from "shared/models/usersRecruteur.model" +import { IUser2 } from "shared/models/user2.model" import { ISecuredRouteSchema, WithSecurityScheme } from "shared/routes/common.routes" import { UserWithType } from "shared/security/permissions" import { Credential } from "@/common/model" import config from "@/config" import { getSession } from "@/services/sessions.service" -import { getUserRecruteurByEmail, updateLastConnectionDate } from "@/services/userRecruteur.service" +import { getUser2ByEmail } from "@/services/user2.service" +import { updateLastConnectionDate } from "@/services/userRecruteur.service" import { controlUserState } from "../services/login.service" import { IAccessToken, parseAccessToken } from "./accessTokenService" -export type IUserWithType = UserWithType<"IUserRecruteur", IUserRecruteur> | UserWithType<"ICredential", ICredential> | UserWithType<"IAccessToken", IAccessToken> +export type IUserWithType = UserWithType<"IUser2", IUser2> | UserWithType<"ICredential", ICredential> | UserWithType<"IAccessToken", IAccessToken> declare module "fastify" { interface FastifyRequest { @@ -26,7 +27,7 @@ declare module "fastify" { } type AuthenticatedUser = AuthScheme extends "cookie-session" - ? UserWithType<"IUserRecruteur", IUserRecruteur> + ? UserWithType<"IUser2", IUser2> : AuthScheme extends "api-key" ? UserWithType<"ICredential", ICredential> : AuthScheme extends "access-token" @@ -40,7 +41,7 @@ export const getUserFromRequest = (req: Pick } -async function authCookieSession(req: FastifyRequest): Promise | null> { +async function authCookieSession(req: FastifyRequest): Promise | null> { const token = req.cookies?.[config.auth.session.cookieName] if (!token) { @@ -56,12 +57,12 @@ async function authCookieSession(req: FastifyRequest): Promise -type NonTokenUserWithType = UserWithType<"IUserRecruteur", IUserRecruteur> | UserWithType<"ICredential", ICredential> +type NonTokenUserWithType = UserWithType<"IUser2", IUser2> | UserWithType<"ICredential", ICredential> // TODO: Unit test access control // TODO: job.delegations @@ -355,7 +356,7 @@ export async function authorizationMiddleware { - const validated = await isCompanyValid(userRecruteur) +export const autoValidateCompany = async (userAndEntreprise: UserAndOrganization) => { + const validated = await isCompanyValid(userAndEntreprise) if (validated) { - userRecruteur = await autoValidateUser(userRecruteur._id) + await autoValidateUser(userAndEntreprise) } else { - if (!(userRecruteur.status.length && getUserStatus(userRecruteur.status) === ETAT_UTILISATEUR.ATTENTE)) { - userRecruteur = await setUserHasToBeManuallyValidated(userRecruteur._id) - } + await setUserHasToBeManuallyValidated(userAndEntreprise) } - return { userRecruteur, validated } + return { validated } } -export const isCompanyValid = async (props: { establishment_siret?: string | null; email: string }): Promise => { - const { establishment_siret: siret, email } = props +export const isCompanyValid = async (props: UserAndOrganization): Promise => { + const { + organization: { siret }, + user: { email }, + } = props if (!siret) { return false } @@ -744,7 +753,7 @@ export const entrepriseOnboardingWorkflow = { }: { isUserValidated?: boolean } = {} - ): Promise => { + ): Promise => { const cfaErrorOpt = await validateCreationEntrepriseFromCfa({ siret, cfa_delegated_siret }) if (cfaErrorOpt) return cfaErrorOpt const formatedEmail = email.toLocaleLowerCase() @@ -775,17 +784,22 @@ export const entrepriseOnboardingWorkflow = { cfa_delegated_siret, }) const formulaireId = formulaireInfo.establishment_id - let { userRecruteur: newEntreprise } = await createUser({ ...savedData, establishment_id: formulaireId, type: ENTREPRISE, is_email_checked: false, is_qualiopi: false }) + const creationResult = await createOrganizationUser({ + ...savedData, + establishment_id: formulaireId, + type: ENTREPRISE, + is_email_checked: false, + is_qualiopi: false, + }) if (hasSiretError) { - newEntreprise = await setUserInError(newEntreprise._id, "Erreur lors de l'appel à l'API SIRET") + await setEntrepriseInError(creationResult.organization._id, "Erreur lors de l'appel à l'API SIRET") } else if (isUserValidated) { - newEntreprise = await autoValidateUser(newEntreprise._id) + await autoValidateUser(creationResult) } else { - const balValidationResult = await autoValidateCompany(newEntreprise) - newEntreprise = balValidationResult.userRecruteur + await autoValidateCompany(creationResult) } - return { formulaire: formulaireInfo, user: newEntreprise } + return { formulaire: formulaireInfo, user: creationResult.user } }, createFromCFA: async ({ email, @@ -858,17 +872,16 @@ export const sendUserConfirmationEmail = async (user: IUser2) => { }) } -export const sendEmailConfirmationEntreprise = async (user: IUserRecruteur, recruteur: IRecruiter) => { - const userStatus = getUserStatus(user.status) - if (userStatus === ETAT_UTILISATEUR.ERROR || user.is_email_checked) { +export const sendEmailConfirmationEntreprise = async (user: IUser2, recruteur: IRecruiter, accessStatus: AccessStatus, entrepriseStatus: EntrepriseStatus) => { + if (entrepriseStatus === EntrepriseStatus.ERROR || isUserEmailChecked(user) || accessStatus === AccessStatus.DENIED) { return } - const isUserAwaiting = userStatus !== ETAT_UTILISATEUR.VALIDE + const isUserAwaiting = accessStatus === AccessStatus.AWAITING_VALIDATION const { jobs, is_delegated, email } = recruteur const offre = jobs.at(0) if (jobs.length === 1 && offre && is_delegated === false) { // Get user account validation link - const url = createValidationMagicLink(user) + const url = createValidationMagicLink(user2ToUserForToken(user)) await mailer.sendEmail({ to: email, subject: "Confirmez votre adresse mail", diff --git a/server/src/services/formulaire.service.ts b/server/src/services/formulaire.service.ts index 2eb4e0e7d7..192b3b2e57 100644 --- a/server/src/services/formulaire.service.ts +++ b/server/src/services/formulaire.service.ts @@ -3,7 +3,8 @@ import type { ObjectId as ObjectIdType } from "mongodb" import pkg from "mongodb" import type { FilterQuery, ModelUpdateOptions, UpdateQuery } from "mongoose" import { IDelegation, IJob, IJobWritable, IRecruiter, IUserRecruteur, JOB_STATUS } from "shared" -import { ETAT_UTILISATEUR, RECRUITER_STATUS } from "shared/constants/recruteur" +import { RECRUITER_STATUS } from "shared/constants/recruteur" +import { EntrepriseStatus, IEntreprise } from "shared/models/entreprise.model" import { AccessEntityType, AccessStatus } from "shared/models/roleManagement.model" import { IUser2 } from "shared/models/user2.model" import { getLastStatusEvent } from "shared/utils/getLastStatusEvent" @@ -22,7 +23,6 @@ import dayjs from "./dayjs.service" import { sendEmailConfirmationEntreprise } from "./etablissement.service" import mailer, { sanitizeForEmail } from "./mailer.service" import { getRomeDetailsFromDB } from "./rome.service" -import { getUserRecruteurByRecruiter, getUserStatus } from "./userRecruteur.service" const { ObjectId } = pkg @@ -90,43 +90,67 @@ export const getFormulaires = async (query: FilterQuery, select: obj /** * @description Create job offer for formulaire */ -export const createJob = async ({ job, id }: { job: IJobWritable; id: string }): Promise => { - // get user data +export const createJob = async ({ job, id, user }: { job: IJobWritable; id: string; user: IUser2 }): Promise => { + const userId = user._id const recruiter = await Recruiter.findOne({ establishment_id: id }).lean() if (!recruiter) { throw Boom.internal(`recruiter with establishment_id=${id} not found`) } - const userRecruteur = await getUserRecruteurByRecruiter(recruiter) - const userStatus: ETAT_UTILISATEUR | null = (userRecruteur ? getUserStatus(userRecruteur.status) : null) ?? null - const isUserAwaiting = userStatus !== ETAT_UTILISATEUR.VALIDE + const { is_delegated, cfa_delegated_siret } = recruiter + const organization = await (cfa_delegated_siret ? Cfa.findOne({ siret: cfa_delegated_siret }).lean() : Entreprise.findOne({ establishment_id: id }).lean()) + if (!organization) { + throw Boom.internal(`inattendu : impossible retrouver l'organisation pour establishment_id=${id}`) + } + const role = await RoleManagement.findOne({ + user_id: userId, + authorized_type: cfa_delegated_siret ? AccessEntityType.CFA : AccessEntityType.ENTREPRISE, + authorized_id: organization._id.toString(), + }).lean() + if (!role) { + throw Boom.internal(`inattendu : impossible retrouver le role pour establishment_id=${id}`) + } + const roleStatus = getLastStatusEvent(role.status)?.status + if (!roleStatus) { + throw Boom.internal(`inattendu : pas de status pour le role pour establishment_id=${id}`) + } + const entreprise: IEntreprise | null = cfa_delegated_siret ? null : (organization as IEntreprise) + const entrepriseStatus = getLastStatusEvent(entreprise?.status)?.status + const isJobActive = roleStatus === AccessStatus.GRANTED && cfa_delegated_siret ? true : entrepriseStatus === EntrepriseStatus.VALIDE - const jobPartial: Partial = job - jobPartial.job_status = userRecruteur && isUserAwaiting ? JOB_STATUS.EN_ATTENTE : JOB_STATUS.ACTIVE + const newJobStatus = isJobActive ? JOB_STATUS.ACTIVE : JOB_STATUS.EN_ATTENTE // get user activation state if not managed by a CFA - const codeRome = job.rome_code[0] + const codeRome = job.rome_code.at(0) + if (!codeRome) { + throw Boom.internal(`inattendu : pas de code rome pour une création d'offre pour le recruiter id=${id}`) + } const romeData = await getRomeDetailsFromDB(codeRome) if (!romeData) { throw Boom.internal(`could not find rome infos for rome=${codeRome}`) } const creationDate = new Date() - const { job_start_date = creationDate } = job + const { job_start_date } = job const updatedJob: Partial = Object.assign(job, { + job_status: newJobStatus, job_start_date, rome_detail: romeData.fiche_metier, job_creation_date: creationDate, job_expiration_date: addExpirationPeriod(creationDate).toDate(), job_update_date: creationDate, + managed_by: userId, }) // insert job const updatedFormulaire = await createOffre(id, updatedJob) - const { is_delegated, cfa_delegated_siret, jobs } = updatedFormulaire + const { jobs } = updatedFormulaire const createdJob = jobs.at(jobs.length - 1) if (!createdJob) { throw Boom.internal("unexpected: no job found after job creation") } // if first offer creation for an Entreprise, send specific mail - if (jobs.length === 1 && is_delegated === false && userRecruteur) { - await sendEmailConfirmationEntreprise(userRecruteur, updatedFormulaire) + if (jobs.length === 1 && is_delegated === false && isJobActive) { + if (!entrepriseStatus) { + throw Boom.internal(`inattendu : pas de status pour l'entreprise pour establishment_id=${id}`) + } + await sendEmailConfirmationEntreprise(user, updatedFormulaire, roleStatus, entrepriseStatus) return updatedFormulaire } diff --git a/server/src/services/login.service.ts b/server/src/services/login.service.ts index 0ef6dd74eb..209f5a9357 100644 --- a/server/src/services/login.service.ts +++ b/server/src/services/login.service.ts @@ -1,27 +1,43 @@ import Boom from "boom" -import { IUserRecruteur, assertUnreachable } from "shared" -import { ETAT_UTILISATEUR } from "shared/constants/recruteur" +import { assertUnreachable } from "shared" +import { EntrepriseStatus } from "shared/models/entreprise.model" +import { AccessEntityType, AccessStatus } from "shared/models/roleManagement.model" +import { IUser2, UserEventType } from "shared/models/user2.model" +import { getLastStatusEvent } from "shared/utils/getLastStatusEvent" -import { getUserStatus } from "./userRecruteur.service" +import { Entreprise, RoleManagement } from "@/common/model" -export const controlUserState = (status: IUserRecruteur["status"]): { error: boolean; reason?: string } => { - const currentState = getUserStatus(status) - switch (currentState) { - case ETAT_UTILISATEUR.ATTENTE: - case ETAT_UTILISATEUR.ERROR: - return { error: true, reason: "VALIDATION" } - - case ETAT_UTILISATEUR.DESACTIVE: +export const controlUserState = async (user: IUser2): Promise<{ error: boolean; reason?: string }> => { + const status = getLastStatusEvent(user.status)?.status + switch (status) { + case UserEventType.DESACTIVE: return { error: true, reason: "DISABLED" } - - case ETAT_UTILISATEUR.VALIDE: - return { error: false } - + case UserEventType.VALIDATION_EMAIL: + case UserEventType.ACTIF: { + const roles = await RoleManagement.find({ user_id: user._id.toString() }).lean() + const rolesWithAccess = roles.filter((role) => getLastStatusEvent(role.status)?.status === AccessStatus.GRANTED) + if (!rolesWithAccess.length) { + return { error: true, reason: "VALIDATION" } + } + const cfaRoles = rolesWithAccess.filter((role) => role.authorized_type === AccessEntityType.CFA) + if (cfaRoles.length) { + return { error: false } + } + const entrepriseRoles = rolesWithAccess.filter((role) => role.authorized_type === AccessEntityType.ENTREPRISE) + if (entrepriseRoles.length) { + const entreprises = await Entreprise.find({ _id: { $in: entrepriseRoles.map((role) => role.authorized_id) } }).lean() + const hasSomeEntrepriseReady = entreprises.some((entreprise) => getLastStatusEvent(entreprise.status)?.status === EntrepriseStatus.VALIDE) + if (hasSomeEntrepriseReady) { + return { error: false } + } + } + return { error: true, reason: "VALIDATION" } + } case null: case undefined: throw Boom.badRequest("L'état utilisateur est inconnu") default: - assertUnreachable(currentState) + assertUnreachable(status) } } diff --git a/server/src/services/organization.service.ts b/server/src/services/organization.service.ts new file mode 100644 index 0000000000..0b637eba85 --- /dev/null +++ b/server/src/services/organization.service.ts @@ -0,0 +1,55 @@ +import Boom from "boom" +import { IUserRecruteur } from "shared/models" +import { ICFA } from "shared/models/cfa.model" +import { IEntreprise } from "shared/models/entreprise.model" + +import { Cfa, Entreprise } from "@/common/model" + +import { CFA, ENTREPRISE } from "./constant.service" + +export const createOrganizationIfNotExist = async (organization: Omit): Promise => { + const { address, address_detail, establishment_enseigne, establishment_id, establishment_raison_sociale, establishment_siret, geo_coordinates, idcc, opco, origin, type } = + organization + + if (!establishment_siret) { + throw Boom.internal("siret is missing") + } + if (type === ENTREPRISE || type === CFA) { + let entreprise = await Entreprise.findOne({ siret: establishment_siret }).lean() + if (!entreprise) { + const entrepriseFields: Omit = { + siret: establishment_siret, + address, + address_detail, + enseigne: establishment_enseigne, + raison_sociale: establishment_raison_sociale, + establishment_id, + origin, + opco, + idcc, + geo_coordinates, + status: [], + } + entreprise = await Entreprise.create(entrepriseFields) + } + if (type === CFA) { + let cfa = await Cfa.findOne({ siret: establishment_siret }).lean() + if (!cfa) { + const cfaFields: Omit = { + siret: establishment_siret, + address, + address_detail, + enseigne: establishment_enseigne, + raison_sociale: establishment_raison_sociale, + origin, + geo_coordinates, + } + cfa = await Cfa.create(cfaFields) + } + return cfa + } + return entreprise + } else { + throw Boom.internal(`type unsupported: ${type}`) + } +} diff --git a/server/src/services/user.service.ts b/server/src/services/user.service.ts index 23be45c455..38b0d408af 100644 --- a/server/src/services/user.service.ts +++ b/server/src/services/user.service.ts @@ -1,5 +1,5 @@ import type { FilterQuery } from "mongoose" -import { IUser, IUserRecruteur } from "shared" +import { IUser } from "shared" import { ETAT_UTILISATEUR } from "shared/constants/recruteur" import { IUserForOpco } from "shared/routes/user.routes" @@ -140,14 +140,10 @@ const getUserAndRecruitersDataForOpcoUser = async ( return results } -const getValidatorIdentityFromStatus = async (status: IUserRecruteur["status"]) => { - return await Promise.all( - status.map(async (state) => { - if (state.user === "SERVEUR") return state - const user = await User2.findById(state.user).select({ first_name: 1, last_name: 1, _id: 0 }).lean() - return { ...state, user: `${user?.first_name} ${user?.last_name}` } - }) - ) +export const getUserNamesFromIds = async (ids: string[]) => { + const deduplicatedIds = [...new Set(ids)] + const users = await User2.find({ _id: { $in: deduplicatedIds } }).lean() + return users } -export { createUser, find, findOne, getUserAndRecruitersDataForOpcoUser, getUserById, getUserByMail, getValidatorIdentityFromStatus, update } +export { createUser, find, findOne, getUserAndRecruitersDataForOpcoUser, getUserById, getUserByMail, update } diff --git a/server/src/services/user2.service.ts b/server/src/services/user2.service.ts new file mode 100644 index 0000000000..d362af50a5 --- /dev/null +++ b/server/src/services/user2.service.ts @@ -0,0 +1,41 @@ +import { VALIDATION_UTILISATEUR } from "shared/constants/recruteur" +import { IUser2, IUserStatusEvent, UserEventType } from "shared/models/user2.model" + +import { User2 } from "@/common/model" + +export const createUser2IfNotExist = async (userProps: Omit, is_email_checked: boolean): Promise => { + const { first_name, last_name, last_action_date, origin, phone } = userProps + const formatedEmail = userProps.email.toLocaleLowerCase() + + let user = await User2.findOne({ email: formatedEmail }).lean() + if (!user) { + const status: IUserStatusEvent[] = [] + if (is_email_checked) { + status.push({ + date: new Date(), + reason: "creation", + status: UserEventType.VALIDATION_EMAIL, + validation_type: VALIDATION_UTILISATEUR.MANUAL, + }) + } + status.push({ + date: new Date(), + reason: "creation", + status: UserEventType.ACTIF, + validation_type: VALIDATION_UTILISATEUR.MANUAL, + }) + const userFields: Omit = { + email: formatedEmail, + first_name, + last_name, + phone: phone ?? "", + last_action_date: last_action_date ?? new Date(), + origin, + status, + } + user = await User2.create(userFields) + } + return user +} + +export const getUser2ByEmail = async (email: string): Promise => User2.findOne({ email: email.toLocaleLowerCase() }).lean() diff --git a/server/src/services/userRecruteur.service.ts b/server/src/services/userRecruteur.service.ts index f5ac728ff7..a023f3bb98 100644 --- a/server/src/services/userRecruteur.service.ts +++ b/server/src/services/userRecruteur.service.ts @@ -5,7 +5,8 @@ import { ObjectId } from "mongodb" import type { FilterQuery, ModelUpdateOptions, UpdateQuery } from "mongoose" import { IRecruiter, IUserRecruteur, IUserRecruteurWritable, IUserStatusValidation, UserRecruteurForAdminProjection, assertUnreachable } from "shared" import { CFA, ENTREPRISE, ETAT_UTILISATEUR, OPCOS, VALIDATION_UTILISATEUR } from "shared/constants/recruteur" -import { EntrepriseStatus, IEntrepriseStatusEvent } from "shared/models/entreprise.model" +import { ICFA } from "shared/models/cfa.model" +import { EntrepriseStatus, IEntreprise, IEntrepriseStatusEvent } from "shared/models/entreprise.model" import { AccessEntityType, AccessStatus, IRoleManagement, IRoleManagementEvent } from "shared/models/roleManagement.model" import { IUser2, IUserStatusEvent, UserEventType } from "shared/models/user2.model" import { getLastStatusEvent } from "shared/utils/getLastStatusEvent" @@ -13,6 +14,7 @@ import { entriesToTypedRecord, typedKeys } from "shared/utils/objectUtils" import { parseEnumOrError } from "@/common/utils/enumUtils" import { getStaticFilePath } from "@/common/utils/getStaticFilePath" +import { user2ToUserForToken } from "@/security/accessTokenService" import { Cfa, Entreprise, RoleManagement, User2, UserRecruteur } from "../common/model/index" import config from "../config" @@ -20,7 +22,9 @@ import config from "../config" import { createAuthMagicLink } from "./appLinks.service" import { ADMIN, OPCO } from "./constant.service" import mailer, { sanitizeForEmail } from "./mailer.service" +import { createOrganizationIfNotExist } from "./organization.service" import { modifyPermissionToUser } from "./permissions.service" +import { createUser2IfNotExist } from "./user2.service" /** * @description generate an API key @@ -39,44 +43,17 @@ const entrepriseStatusEventToUserRecruteurStatusEvent = (entrepriseStatusEvent: } } -const getOrganismeFromRole = async (role: IRoleManagement): Promise | null> => { +const getOrganismeFromRole = async (role: IRoleManagement): Promise => { switch (role.authorized_type) { case AccessEntityType.ENTREPRISE: { const entreprise = await Entreprise.findOne({ _id: role.authorized_id }).lean() if (!entreprise) return null - const { siret, address, address_detail, establishment_id, geo_coordinates, idcc, opco, origin, raison_sociale, enseigne, status } = entreprise - const lastStatus = getLastStatusEvent(status) - - return { - establishment_siret: siret, - establishment_enseigne: enseigne, - establishment_raison_sociale: raison_sociale, - address, - address_detail, - establishment_id, - geo_coordinates, - idcc, - opco, - origin, - type: ENTREPRISE, - status: lastStatus?.status === EntrepriseStatus.ERROR ? [entrepriseStatusEventToUserRecruteurStatusEvent(lastStatus, ETAT_UTILISATEUR.ERROR)] : [], - } + return entreprise } case AccessEntityType.CFA: { const cfa = await Cfa.findOne({ _id: role.authorized_id }).lean() if (!cfa) return null - const { siret, address, address_detail, geo_coordinates, origin, raison_sociale, enseigne } = cfa - return { - establishment_siret: siret, - establishment_enseigne: enseigne, - establishment_raison_sociale: raison_sociale, - address, - address_detail, - geo_coordinates, - origin, - type: CFA, - is_qualiopi: true, - } + return cfa } default: return null @@ -121,13 +98,10 @@ export const getUserRecruteurByRecruiter = async (recruiter: IRecruiter): Promis } } -const getUserRecruteurByUser2Query = async (user2query: Partial): Promise => { - const user = await User2.findOne(user2query).lean() - if (!user) return null - const role = await RoleManagement.findOne({ user_id: user._id.toString() }).lean() - if (!role) return null - const organismeData = await getOrganismeFromRole(role) +export const userAndRoleAndOrganizationToUserRecruteur = (user: IUser2, role: IRoleManagement, organisme: ICFA | IEntreprise): IUserRecruteur => { const { email, first_name, last_name, phone, last_action_date, _id } = user + const organismeType = "status" in organisme ? ENTREPRISE : CFA + const lastEntrepriseEvent = "status" in organisme ? getLastStatusEvent(organisme.status) : null const oldStatus: IUserStatusValidation[] = [ ...role.status.map(({ date, reason, status, validation_type, granted_by }) => { const userRecruteurStatus = roleStatusToUserRecruteurStatus(status) @@ -139,16 +113,32 @@ const getUserRecruteurByUser2Query = async (user2query: Partial): Promis user: granted_by ?? "", } }), - ...(organismeData?.status ?? []), + ...(lastEntrepriseEvent?.status === EntrepriseStatus.ERROR ? [entrepriseStatusEventToUserRecruteurStatusEvent(lastEntrepriseEvent, ETAT_UTILISATEUR.ERROR)] : []), ] - const roleType = role.authorized_type === AccessEntityType.OPCO ? "OPCO" : role.authorized_type === AccessEntityType.ADMIN ? "ADMIN" : null - const organismeType = organismeData?.type + const roleType = role.authorized_type === AccessEntityType.OPCO ? OPCO : role.authorized_type === AccessEntityType.ADMIN ? ADMIN : null const type = roleType ?? organismeType ?? null if (!type) throw Boom.internal("unexpected: no type found") - return { - ...organismeData, - createdAt: organismeData?.createdAt ?? user.createdAt, - updatedAt: organismeData?.updatedAt ?? user.updatedAt, + const { siret, address, address_detail, geo_coordinates, origin, raison_sociale, enseigne } = organisme + const entrepriseFields = + "idcc" in organisme + ? { + idcc: organisme.idcc, + opco: organisme.opco, + establishment_id: organisme.establishment_id, + } + : {} + const userRecruteur: IUserRecruteur = { + ...entrepriseFields, + establishment_siret: siret, + establishment_enseigne: enseigne, + establishment_raison_sociale: raison_sociale, + address, + address_detail, + geo_coordinates, + origin, + is_qualiopi: type === CFA, + createdAt: role?.createdAt ?? user.createdAt, + updatedAt: role?.updatedAt ?? user.updatedAt, is_email_checked: isUserEmailChecked(user), type, _id, @@ -159,116 +149,130 @@ const getUserRecruteurByUser2Query = async (user2query: Partial): Promis last_connection: last_action_date, status: oldStatus, } + return userRecruteur +} + +const getUserRecruteurByUser2Query = async (user2query: Partial): Promise => { + const user = await User2.findOne(user2query).lean() + if (!user) return null + const role = await RoleManagement.findOne({ user_id: user._id.toString() }).lean() + if (!role) return null + const organisme = await getOrganismeFromRole(role) + if (!organisme) return null + return userAndRoleAndOrganizationToUserRecruteur(user, role, organisme) +} + +export const createOrganizationUser = async ( + userRecruteurProps: Omit, + statusEvent?: Pick +): Promise => { + const { type, origin, first_name, last_name, last_connection, email, is_email_checked, phone } = userRecruteurProps + if (type === ENTREPRISE || type === CFA) { + const user = await createUser2IfNotExist( + { + email, + first_name, + last_name, + phone: phone ?? "", + last_action_date: last_connection, + }, + is_email_checked + ) + const organization = await createOrganizationIfNotExist(userRecruteurProps) + await modifyPermissionToUser( + { + user_id: user._id, + authorized_id: organization._id.toString(), + authorized_type: type === ENTREPRISE ? AccessEntityType.ENTREPRISE : AccessEntityType.CFA, + origin: origin ?? "createUser", + }, + statusEvent ?? { + validation_type: VALIDATION_UTILISATEUR.AUTO, + status: type === ENTREPRISE ? AccessStatus.AWAITING_VALIDATION : AccessStatus.GRANTED, + reason: "", + } + ) + return { organization, user, type } + } else { + throw Boom.internal(`unsupported type ${type}`) + } +} + +export const createOpcoUser = async (userProps: Pick, opco: OPCOS) => { + const user = await createUser2IfNotExist( + { + ...userProps, + last_action_date: new Date(), + }, + false + ) + await modifyPermissionToUser( + { + user_id: user._id, + authorized_id: opco, + authorized_type: AccessEntityType.OPCO, + origin: "", + }, + { + validation_type: VALIDATION_UTILISATEUR.AUTO, + status: AccessStatus.GRANTED, + reason: "", + } + ) + return user +} + +export const createAdminUser = async (userProps: Pick) => { + const user = await createUser2IfNotExist( + { + ...userProps, + last_action_date: new Date(), + }, + false + ) + await modifyPermissionToUser( + { + user_id: user._id, + authorized_id: "", + authorized_type: AccessEntityType.ADMIN, + origin: "", + }, + { + validation_type: VALIDATION_UTILISATEUR.AUTO, + status: AccessStatus.GRANTED, + reason: "", + } + ) + return user } /** * @description création d'un nouveau user recruteur. Le champ status peut être passé ou, s'il n'est pas passé, être sauvé ultérieurement */ export const createUser = async ( - userRecruteurProps: Omit & { statusEvent?: IRoleManagementEvent } -): Promise<{ userRecruteur: IUserRecruteur; user: IUser2 }> => { - const { + userProps: Omit, + statusEvent?: Pick +): Promise => { + const { first_name, last_name, email, phone, type, opco } = userProps + const userFields = { first_name, - is_email_checked, last_name, - type, - address, - address_detail, - establishment_enseigne, - establishment_id, - establishment_raison_sociale, - establishment_siret, - geo_coordinates, - idcc, - last_connection, - opco, - origin, - phone, - // eslint-disable-next-line @typescript-eslint/no-unused-vars - scope, - statusEvent, - } = userRecruteurProps - const formatedEmail = userRecruteurProps.email.toLocaleLowerCase() - - let user = await User2.findOne({ email: formatedEmail }).lean() - if (!user) { - const status: IUserStatusEvent[] = [] - if (is_email_checked) { - status.push({ - date: new Date(), - reason: "creation", - status: UserEventType.VALIDATION_EMAIL, - validation_type: VALIDATION_UTILISATEUR.MANUAL, - }) - } - status.push({ - date: new Date(), - reason: "creation", - status: UserEventType.ACTIF, - validation_type: VALIDATION_UTILISATEUR.MANUAL, - }) - const userFields: Omit = { - email: formatedEmail, - first_name, - last_name, - phone: phone ?? "", - last_action_date: last_connection ?? new Date(), - origin, - status, - } - user = await User2.create(userFields) + email, + phone: phone ?? "", } - let addedRole: Parameters[0] | null = null + if (type === ENTREPRISE || type === CFA) { - if (!establishment_siret) { - throw Boom.internal("siret is missing") - } - let entreprise = await Entreprise.findOne({ siret: establishment_siret }).lean() - if (!entreprise) { - entreprise = await Entreprise.create({ - siret: establishment_siret, - address, - address_detail, - enseigne: establishment_enseigne, - raison_sociale: establishment_raison_sociale, - establishment_id, - origin, - opco, - idcc, - geo_coordinates, - }) - } - addedRole = { - user_id: user._id, - authorized_id: entreprise._id.toString(), - authorized_type: AccessEntityType.ENTREPRISE, - origin: origin ?? "createUser", - } + const { user } = await createOrganizationUser(userProps, statusEvent) + return user } else if (type === ADMIN) { - addedRole = { - user_id: user._id, - authorized_id: "", - authorized_type: AccessEntityType.ADMIN, - origin: origin ?? "createUser", - } + const user = await createAdminUser(userFields) + return user } else if (type === OPCO) { - addedRole = { - user_id: user._id, - authorized_id: parseEnumOrError(OPCOS, opco ?? null), - authorized_type: AccessEntityType.OPCO, - origin: origin ?? "createUser", - } + const user = await createOpcoUser(userFields, parseEnumOrError(OPCOS, opco ?? null)) + return user } else { assertUnreachable(type) } - if (statusEvent && addedRole) { - await modifyPermissionToUser(addedRole, statusEvent) - } - const userRecruteur = await getUserRecruteurById(user._id) - if (!userRecruteur) { - throw Boom.internal("unexpected") - } - return { userRecruteur, user } } /** @@ -355,41 +359,49 @@ export const getUserStatus = (stateArray: IUserRecruteur["status"]): IUserStatus return lastValidationEvent.status } -export const setUserInError = async (userId: IUserRecruteur["_id"], reason: string) => { - const response = await updateUserValidationHistory(userId, { - validation_type: VALIDATION_UTILISATEUR.AUTO, - user: "SERVEUR", - status: ETAT_UTILISATEUR.ERROR, +export const setEntrepriseInError = async (entrepriseId: IEntreprise["_id"], reason: string) => { + const entreprise = await Entreprise.findOne({ _id: entrepriseId }) + if (!entreprise) { + throw Boom.internal(`could not find entreprise with id=${entrepriseId}`) + } + const event: IEntrepriseStatusEvent = { + date: new Date(), reason, - }) - if (!response) { - throw new Error(`could not find user history for user with id=${userId}`) + status: EntrepriseStatus.ERROR, + validation_type: VALIDATION_UTILISATEUR.AUTO, } - return response + await Entreprise.updateOne( + { _id: entrepriseId }, + { + $push: { + status: event, + }, + } + ) } -export const autoValidateUser = async (userId: IUserRecruteur["_id"]) => { - const response = await updateUserValidationHistory(userId, { - validation_type: VALIDATION_UTILISATEUR.AUTO, - user: "SERVEUR", - status: ETAT_UTILISATEUR.VALIDE, - }) - if (!response) { - throw new Error(`could not find user history for user with id=${userId}`) - } - return response +const setAccessOfUserOnOrganization = async ({ user, organization, type }: UserAndOrganization, status: AccessStatus) => { + await modifyPermissionToUser( + { + user_id: user._id, + authorized_id: organization._id.toString(), + authorized_type: type === ENTREPRISE ? AccessEntityType.ENTREPRISE : AccessEntityType.CFA, + origin: "", + }, + { + validation_type: VALIDATION_UTILISATEUR.AUTO, + status, + reason: "", + } + ) } -export const setUserHasToBeManuallyValidated = async (userId: IUserRecruteur["_id"]) => { - const response = await updateUserValidationHistory(userId, { - validation_type: VALIDATION_UTILISATEUR.AUTO, - user: "SERVEUR", - status: ETAT_UTILISATEUR.ATTENTE, - }) - if (!response) { - throw new Error(`could not find user history for user with id=${userId}`) - } - return response +export const autoValidateUser = async (props: UserAndOrganization) => { + await setAccessOfUserOnOrganization(props, AccessStatus.GRANTED) +} + +export const setUserHasToBeManuallyValidated = async (props: UserAndOrganization) => { + await setAccessOfUserOnOrganization(props, AccessStatus.AWAITING_VALIDATION) } export const deactivateUser = async (userId: IUserRecruteur["_id"], reason?: string) => { @@ -405,8 +417,18 @@ export const deactivateUser = async (userId: IUserRecruteur["_id"], reason?: str return response } -export const sendWelcomeEmailToUserRecruteur = async (userRecruteur: IUserRecruteur) => { - const { email, first_name, last_name, establishment_raison_sociale, type } = userRecruteur +export const sendWelcomeEmailToUserRecruteur = async (user: IUser2) => { + const { email, first_name, last_name } = user + const role = await RoleManagement.findOne({ authorized_type: { $in: [AccessEntityType.ENTREPRISE, AccessEntityType.CFA] } }).lean() + if (!role) { + throw Boom.internal(`inattendu : pas de role pour user id=${user._id}`) + } + const isCfa = role.authorized_type === AccessEntityType.CFA + const organization = await (isCfa ? Cfa : Entreprise).findOne({ _id: role.authorized_id }).lean() + if (!organization) { + throw Boom.internal(`inattendu : pas d'organization pour user id=${user._id} et role id=${role._id}`) + } + const { raison_sociale: establishment_raison_sociale } = organization await mailer.sendEmail({ to: email, subject: "Bienvenue sur La bonne alternance", @@ -419,8 +441,8 @@ export const sendWelcomeEmailToUserRecruteur = async (userRecruteur: IUserRecrut last_name: sanitizeForEmail(last_name), first_name: sanitizeForEmail(first_name), email: sanitizeForEmail(email), - is_delegated: type === CFA, - url: createAuthMagicLink(userRecruteur), + is_delegated: isCfa, + url: createAuthMagicLink(user2ToUserForToken(user)), }, }) } @@ -477,3 +499,5 @@ export const getUsersWithRoles = async () => { } export const isUserEmailChecked = (user: IUser2): boolean => user.status.some((event) => event.status === UserEventType.VALIDATION_EMAIL) + +export type UserAndOrganization = { user: IUser2; organization: IEntreprise | ICFA; type: "ENTREPRISE" | "CFA" } From 9b9c2e0fe066414c64a4dd14ebe2c37cece09ab3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9my=20Auricoste?= Date: Tue, 5 Mar 2024 16:29:03 +0100 Subject: [PATCH 24/78] fix: fichiers manquants + modification d'acces via /history --- .../src/http/controllers/user.controller.ts | 44 +++++++++++++------ server/src/services/permissions.service.ts | 3 +- shared/models/usersRecruteur.model.ts | 24 ++-------- shared/routes/recruiters.routes.ts | 5 ++- shared/routes/user.routes.ts | 23 +++++----- ui/common/hooks/useUserHistoryUpdate.ts | 5 +-- .../Admin/utilisateurs/UserForm.tsx | 2 +- .../Admin/utilisateurs/UserList.tsx | 2 +- .../InformationCreationCompte.tsx | 15 ++++--- .../espace_pro/ConfirmationCreationCompte.tsx | 22 +++++++--- ui/utils/api.ts | 2 +- 11 files changed, 82 insertions(+), 65 deletions(-) diff --git a/server/src/http/controllers/user.controller.ts b/server/src/http/controllers/user.controller.ts index b599e9adb9..1b8b8959ce 100644 --- a/server/src/http/controllers/user.controller.ts +++ b/server/src/http/controllers/user.controller.ts @@ -1,5 +1,5 @@ import Boom from "boom" -import { CFA, ETAT_UTILISATEUR, VALIDATION_UTILISATEUR } from "shared/constants/recruteur" +import { CFA, VALIDATION_UTILISATEUR } from "shared/constants/recruteur" import { IJob, getUserStatus, zRoutes } from "shared/index" import { IEntreprise } from "shared/models/entreprise.model" import { AccessEntityType, AccessStatus } from "shared/models/roleManagement.model" @@ -7,6 +7,7 @@ import { getLastStatusEvent } from "shared/utils/getLastStatusEvent" import { stopSession } from "@/common/utils/session.service" import { getUserFromRequest } from "@/security/authenticationService" +import { modifyPermissionToUser } from "@/services/permissions.service" import { Cfa, Entreprise, Recruiter, RoleManagement, UserRecruteur } from "../../common/model/index" import { getStaticFilePath } from "../../common/utils/getStaticFilePath" @@ -27,7 +28,6 @@ import { sendWelcomeEmailToUserRecruteur, updateUser, updateUser2Fields, - updateUserValidationHistory, userAndRoleAndOrganizationToUserRecruteur, validateUserEmail, } from "../../services/userRecruteur.service" @@ -286,23 +286,40 @@ export default (server: Server) => { ) server.put( - "/user/:userId/history", + "/user/:userId/organization/:organizationId/permission", { - schema: zRoutes.put["/user/:userId/history"], - onRequest: [server.auth(zRoutes.put["/user/:userId/history"])], + schema: zRoutes.put["/user/:userId/organization/:organizationId/permission"], + onRequest: [server.auth(zRoutes.put["/user/:userId/organization/:organizationId/permission"])], }, async (req, res) => { - // TODO gestion couple user/organization - const newEvent = req.body - const validator = getUserFromRequest(req, zRoutes.put["/user/:userId/history"]).value - const user = await updateUserValidationHistory(req.params.userId, { ...newEvent, user: validator._id.toString() }) - + const { reason, status, organizationType } = req.body + const { userId, organizationId } = req.params + const user = getUserFromRequest(req, zRoutes.put["/user/:userId/organization/:organizationId/permission"]).value if (!user) throw Boom.badRequest() + const updatedRole = await modifyPermissionToUser( + { + user_id: userId, + authorized_id: organizationId.toString(), + authorized_type: organizationType, + origin: "action admin ou opco", + }, + { + validation_type: VALIDATION_UTILISATEUR.MANUAL, + reason, + status, + granted_by: user._id.toString(), + } + ) + const { email, last_name, first_name } = user + const newEvent = getLastStatusEvent(updatedRole.status) + if (!newEvent) { + throw Boom.internal("inattendu : aucun event sauvegardé") + } // if user is disabled, return the user data directly - if (newEvent.status === ETAT_UTILISATEUR.DESACTIVE) { + if (newEvent.status === AccessStatus.DENIED) { // send email to user to notify him his account has been disabled await mailer.sendEmail({ to: email, @@ -330,8 +347,9 @@ export default (server: Server) => { return res.status(200).send(user) } - if (user.type === ENTREPRISE) { - const { establishment_id } = user + if (organizationType === AccessEntityType.ENTREPRISE) { + const entreprise = await Entreprise.findOne({ _id: organizationId }).lean() + const establishment_id = entreprise?.establishment_id if (!establishment_id) { throw Boom.internal("unexpected: no establishment_id on userRecruteur of type ENTREPRISE", { userId: user._id }) } diff --git a/server/src/services/permissions.service.ts b/server/src/services/permissions.service.ts index de5ceb5d57..11945c4b40 100644 --- a/server/src/services/permissions.service.ts +++ b/server/src/services/permissions.service.ts @@ -12,7 +12,8 @@ export const modifyPermissionToUser = async ( ...eventProps, date: new Date(), } - const role = await RoleManagement.findOne(props).lean() + const { authorized_id, authorized_type, user_id } = props + const role = await RoleManagement.findOne({ authorized_id, authorized_type, user_id }).lean() if (role) { const lastEvent = getLastStatusEvent(role.status) if (lastEvent?.status === eventProps.status) { diff --git a/shared/models/usersRecruteur.model.ts b/shared/models/usersRecruteur.model.ts index 695f72003b..ba1fb3ff93 100644 --- a/shared/models/usersRecruteur.model.ts +++ b/shared/models/usersRecruteur.model.ts @@ -1,6 +1,6 @@ import { Jsonify } from "type-fest" -import { AUTHTYPE, CFA, ETAT_UTILISATEUR } from "../constants/recruteur" +import { AUTHTYPE, ETAT_UTILISATEUR } from "../constants/recruteur" import { removeUrlsFromText } from "../helpers/common" import { extensions } from "../helpers/zodHelpers/zodPrimitives" import { z } from "../helpers/zodWithOpenApi" @@ -8,7 +8,7 @@ import { z } from "../helpers/zodWithOpenApi" import { ZGlobalAddress, ZPointGeometry } from "./address.model" import { zObjectId } from "./common" import { enumToZod } from "./enumToZod" -import { ZValidationUtilisateur } from "./user2.model" +import { IUser2, ZValidationUtilisateur } from "./user2.model" export const ZEtatUtilisateur = enumToZod(ETAT_UTILISATEUR).describe("Statut de l'utilisateur") @@ -112,15 +112,6 @@ export const ZUserRecruteurPublic = ZUserRecruteur.pick({ last_name: true, first_name: true, phone: true, - opco: true, - idcc: true, - scope: true, - establishment_siret: true, - establishment_id: true, -}).extend({ - is_delegated: z.boolean(), - cfa_delegated_siret: extensions.siret.nullish(), - status_current: ZEtatUtilisateur.nullish(), }) export type IUserRecruteurPublic = Jsonify> @@ -138,22 +129,13 @@ export const getUserStatus = (stateArray: IUserRecruteur["status"]) => { return lastValidationEvent.status } -export function toPublicUser(user: IUserRecruteur): z.output { +export function toPublicUser(user: IUser2): z.output { return { _id: user._id, email: user.email, - type: user.type, last_name: user.last_name, first_name: user.first_name, phone: user.phone, - opco: user.opco, - idcc: user.idcc, - scope: user.scope, - establishment_siret: user.establishment_siret, - establishment_id: user.establishment_id, - is_delegated: user.type === CFA ? true : false, - cfa_delegated_siret: user.type === CFA ? user.establishment_siret : undefined, - status_current: getUserStatus(user.status), } } diff --git a/shared/routes/recruiters.routes.ts b/shared/routes/recruiters.routes.ts index 294e55b3b3..d339812007 100644 --- a/shared/routes/recruiters.routes.ts +++ b/shared/routes/recruiters.routes.ts @@ -4,7 +4,8 @@ import { extensions } from "../helpers/zodHelpers/zodPrimitives" import { z } from "../helpers/zodWithOpenApi" import { ZPointGeometry, ZRecruiter } from "../models" import { zObjectId } from "../models/common" -import { ZCfaReferentielData, ZUserRecruteur, ZUserRecruteurPublic, ZUserRecruteurWritable } from "../models/usersRecruteur.model" +import { ZUser2 } from "../models/user2.model" +import { ZCfaReferentielData, ZUserRecruteurPublic, ZUserRecruteurWritable } from "../models/usersRecruteur.model" import { IRoutesDef } from "./common.routes" @@ -165,7 +166,7 @@ export const zRecruiterRoutes = { "200": z .object({ formulaire: ZRecruiter.optional(), - user: ZUserRecruteur, + user: ZUser2, token: z.string().optional(), }) .strict(), diff --git a/shared/routes/user.routes.ts b/shared/routes/user.routes.ts index 577f2ff13a..3d049940e4 100644 --- a/shared/routes/user.routes.ts +++ b/shared/routes/user.routes.ts @@ -1,7 +1,8 @@ import { z } from "../helpers/zodWithOpenApi" import { ZJob, ZRecruiter } from "../models" import { zObjectId } from "../models/common" -import { ZEtatUtilisateur, ZUserRecruteur, ZUserRecruteurForAdmin, ZUserRecruteurWritable, ZUserStatusValidation } from "../models/usersRecruteur.model" +import { AccessEntityType, ZRoleManagementEvent } from "../models/roleManagement.model" +import { ZEtatUtilisateur, ZUserRecruteur, ZUserRecruteurForAdmin, ZUserRecruteurWritable } from "../models/usersRecruteur.model" import { IRoutesDef, ZResError } from "./common.routes" @@ -107,13 +108,14 @@ export const zUserRecruteurRoutes = { resources: {}, }, }, - "/user/:userId": { + "/user/:userId/organization/:organizationId": { method: "get", - path: "/user/:userId", + path: "/user/:userId/organization/:organizationId", // TODO_SECURITY_FIX enlever les données privées (dont last connection date) params: z .object({ userId: z.string(), + organizationId: z.string(), }) .strict(), response: { @@ -188,7 +190,7 @@ export const zUserRecruteurRoutes = { status: true, }), response: { - "200": ZUserRecruteur, + "200": z.object({ _id: zObjectId }).strict(), }, securityScheme: { auth: "cookie-session", @@ -235,17 +237,18 @@ export const zUserRecruteurRoutes = { resources: {}, }, }, - "/user/:userId/history": { + "/user/:userId/organization/:organizationId/permission": { method: "put", - path: "/user/:userId/history", - params: z.object({ userId: zObjectId }).strict(), - body: ZUserStatusValidation.pick({ - validation_type: true, + path: "/user/:userId/organization/:organizationId/permission", + params: z.object({ userId: zObjectId, organizationId: zObjectId }).strict(), + body: ZRoleManagementEvent.pick({ status: true, reason: true, + }).extend({ + organizationType: z.enum([AccessEntityType.ENTREPRISE, AccessEntityType.CFA]), }), response: { - "200": ZUserRecruteur, + "200": z.object({}).strict(), }, securityScheme: { auth: "cookie-session", diff --git a/ui/common/hooks/useUserHistoryUpdate.ts b/ui/common/hooks/useUserHistoryUpdate.ts index a9f7d9dedc..a5d56224e8 100644 --- a/ui/common/hooks/useUserHistoryUpdate.ts +++ b/ui/common/hooks/useUserHistoryUpdate.ts @@ -3,12 +3,9 @@ import { useCallback } from "react" import { useQueryClient } from "react-query" import { ETAT_UTILISATEUR, VALIDATION_UTILISATEUR } from "shared/constants/recruteur" -import { useAuth } from "@/context/UserContext" - import { updateUserValidationHistory } from "../../utils/api" export default function useUserHistoryUpdate(userId: string, status: ETAT_UTILISATEUR, reason?: string) { - const { user } = useAuth() const client = useQueryClient() const toast = useToast() @@ -28,5 +25,5 @@ export default function useUserHistoryUpdate(userId: string, status: ETAT_UTILIS isClosable: true, }) ) - }, [user._id, client, reason, status, toast, userId]) + }, [client, reason, status, toast, userId]) } diff --git a/ui/components/espace_pro/Admin/utilisateurs/UserForm.tsx b/ui/components/espace_pro/Admin/utilisateurs/UserForm.tsx index 7add3d05ac..2c2f082259 100644 --- a/ui/components/espace_pro/Admin/utilisateurs/UserForm.tsx +++ b/ui/components/espace_pro/Admin/utilisateurs/UserForm.tsx @@ -52,7 +52,7 @@ const getActionButtons = (userHistory, userId, confirmationDesactivationUtilisat } } -const UserForm = ({ user, onCreate, onDelete, onUpdate }: { user: any; onCreate?: any; onDelete?: any; onUpdate?: any }) => { +const UserForm = ({ user, onCreate, onDelete, onUpdate }: { user: any; onCreate?: (result: void, error?: any) => void; onDelete?: () => void; onUpdate?: () => void }) => { const toast = useToast() const confirmationDesactivationUtilisateur = useDisclosure() const { values, errors, touched, dirty, handleSubmit, handleChange } = useFormik({ diff --git a/ui/components/espace_pro/Admin/utilisateurs/UserList.tsx b/ui/components/espace_pro/Admin/utilisateurs/UserList.tsx index c7dfc5bfa6..9350daed0f 100644 --- a/ui/components/espace_pro/Admin/utilisateurs/UserList.tsx +++ b/ui/components/espace_pro/Admin/utilisateurs/UserList.tsx @@ -50,7 +50,7 @@ const UserList = () => { { + onCreate={async (_, error) => { if (!error) { newUser.onClose() await refetchUsers() diff --git a/ui/components/espace_pro/Authentification/InformationCreationCompte.tsx b/ui/components/espace_pro/Authentification/InformationCreationCompte.tsx index ec0db2f23c..d4cff64278 100644 --- a/ui/components/espace_pro/Authentification/InformationCreationCompte.tsx +++ b/ui/components/espace_pro/Authentification/InformationCreationCompte.tsx @@ -2,8 +2,9 @@ import { Box, Button, Flex, FormControl, FormErrorMessage, FormHelperText, FormL import { Form, Formik } from "formik" import { useRouter } from "next/router" import { useContext, useState } from "react" -import { IUserStatusValidationJson } from "shared" +import { IRecruiterJson, IUserStatusValidationJson } from "shared" import { ETAT_UTILISATEUR } from "shared/constants/recruteur" +import { IUser2Json } from "shared/models/user2.model" import * as Yup from "yup" import { ApiError } from "@/utils/api.utils" @@ -151,7 +152,7 @@ const FormulaireLayout = ({ left, right }) => { export const InformationCreationCompte = ({ isWidget = false }: { isWidget?: boolean }) => { const router = useRouter() const validationPopup = useDisclosure() - const [popupData, setPopupData] = useState({}) + const [popupData, setPopupData] = useState<{ user: IUser2Json; formulaire: IRecruiterJson; token?: string; type: "CFA" | "ENTREPRISE" } | null>(null) const { type, informationSiret: informationSiretString }: { type: "CFA" | "ENTREPRISE"; informationSiret: string } = router.query as any const informationSiret = JSON.parse(informationSiretString || "{}") @@ -159,7 +160,7 @@ export const InformationCreationCompte = ({ isWidget = false }: { isWidget?: boo const submitForm = (values, { setSubmitting, setFieldError }) => { const payload = { ...values, type, establishment_siret } - if (type === "CFA") { + if (type === AUTHTYPE.CFA) { delete payload.opco } createEtablissement(payload) @@ -169,7 +170,7 @@ export const InformationCreationCompte = ({ isWidget = false }: { isWidget?: boo } const statusArray: IUserStatusValidationJson[] = data.user?.status ?? [] if (statusArray?.at(0)?.status === ETAT_UTILISATEUR.VALIDE) { - if (data.user.type === AUTHTYPE.ENTREPRISE) { + if (type === AUTHTYPE.ENTREPRISE) { // Dépot simplifié router.push({ pathname: isWidget ? "/espace-pro/widget/entreprise/offre" : "/espace-pro/creation/offre", @@ -183,7 +184,11 @@ export const InformationCreationCompte = ({ isWidget = false }: { isWidget?: boo } } else { validationPopup.onOpen() - setPopupData({ ...data, type }) + const { user, formulaire } = data + if (!user) { + throw new Error("unexpected: data.user is empty") + } + setPopupData({ user, formulaire, ...data, type }) } setSubmitting(false) }) diff --git a/ui/components/espace_pro/ConfirmationCreationCompte.tsx b/ui/components/espace_pro/ConfirmationCreationCompte.tsx index 9ec24488a1..acbbb7cb48 100644 --- a/ui/components/espace_pro/ConfirmationCreationCompte.tsx +++ b/ui/components/espace_pro/ConfirmationCreationCompte.tsx @@ -1,6 +1,8 @@ import { Box, Button, Center, Heading, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader, ModalOverlay, Stack, Text } from "@chakra-ui/react" import { useRouter } from "next/router" import { useContext } from "react" +import { IRecruiterJson } from "shared" +import { IUser2Json } from "shared/models/user2.model" import { AUTHTYPE } from "../../common/contants" import { redirect } from "../../common/utils/router" @@ -8,17 +10,25 @@ import { WidgetContext } from "../../context/contextWidget" import { InfoCircle } from "../../theme/components/icons" import { deleteCfa, deleteEntreprise } from "../../utils/api" -export const ConfirmationCreationCompte = (props) => { - const { isOpen, onClose, user, formulaire, isWidget, token } = props +export const ConfirmationCreationCompte = (props: { + isOpen: boolean + onClose: () => void + user: IUser2Json + formulaire: IRecruiterJson + isWidget: boolean + type: "ENTREPRISE" | "CFA" + token?: string +}) => { + const { isOpen, onClose, user, formulaire, isWidget, token, type } = props const router = useRouter() const { widget } = useContext(WidgetContext) const validateAccountCreation = () => { - switch (user.type) { + switch (type) { case AUTHTYPE.ENTREPRISE: router.push({ pathname: isWidget ? "/espace-pro/widget/entreprise/offre" : "/espace-pro/creation/offre", - query: { establishment_id: formulaire.establishment_id, email: user.email, type: user.type, userId: user._id, displayBanner: true, token }, + query: { establishment_id: formulaire.establishment_id, email: user.email, type, userId: user._id.toString(), displayBanner: true, token }, }) break case AUTHTYPE.CFA: @@ -32,8 +42,8 @@ export const ConfirmationCreationCompte = (props) => { } const deleteAccount = async () => { - if (user.type === AUTHTYPE.ENTREPRISE) { - await deleteEntreprise(user._id, formulaire._id) + if (type === AUTHTYPE.ENTREPRISE) { + await deleteEntreprise(user._id.toString(), formulaire._id.toString()) } else { await deleteCfa(user._id) } diff --git a/ui/utils/api.ts b/ui/utils/api.ts index f9fa17e0f4..51d1a27dcf 100644 --- a/ui/utils/api.ts +++ b/ui/utils/api.ts @@ -54,7 +54,7 @@ export const createEtablissementDelegationByToken = ({ data, jobId, token }: { j /** * User API */ -export const getUser = (userId: string) => apiGet("/user/:userId", { params: { userId } }) +export const getUser = (userId: string, entrepriseId: string) => apiGet("/user/:userId/organization/:organizationId", { params: { userId, entrepriseId } }) const updateUser = (userId: string, user) => apiPut("/user/:userId", { params: { userId }, body: user }) const updateUserAdmin = (userId: string, user) => apiPut("/admin/users/:userId", { params: { userId }, body: user }) export const getUserStatus = (userId: string) => apiGet("/user/status/:userId", { params: { userId } }) From 0339d224c5d2be4fb927d3ee511b49ef5f286f4d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9my=20Auricoste?= Date: Wed, 6 Mar 2024 14:56:26 +0100 Subject: [PATCH 25/78] fix: update auth --- server/src/security/authenticationService.ts | 17 +- server/src/security/authorisationService.ts | 385 ++++++++---------- .../security/authorisationService.test.ts | 140 +++---- shared/models/applications.model.ts | 1 + shared/routes/user.routes.ts | 1 - shared/security/permissions.ts | 11 +- ui/common/hooks/useUserHistoryUpdate.ts | 11 +- .../administration/users/[userId].tsx | 3 +- ui/utils/api.ts | 18 +- 9 files changed, 272 insertions(+), 315 deletions(-) diff --git a/server/src/security/authenticationService.ts b/server/src/security/authenticationService.ts index a6306de1cd..a7298f8a0a 100644 --- a/server/src/security/authenticationService.ts +++ b/server/src/security/authenticationService.ts @@ -18,7 +18,10 @@ import { controlUserState } from "../services/login.service" import { IAccessToken, parseAccessToken } from "./accessTokenService" -export type IUserWithType = UserWithType<"IUser2", IUser2> | UserWithType<"ICredential", ICredential> | UserWithType<"IAccessToken", IAccessToken> +export type AccessUser2 = UserWithType<"IUser2", IUser2> +export type AccessUserCredential = UserWithType<"ICredential", ICredential> +export type AccessUserToken = UserWithType<"IAccessToken", IAccessToken> +export type IUserWithType = AccessUser2 | AccessUserCredential | AccessUserToken declare module "fastify" { interface FastifyRequest { @@ -27,11 +30,11 @@ declare module "fastify" { } type AuthenticatedUser = AuthScheme extends "cookie-session" - ? UserWithType<"IUser2", IUser2> + ? AccessUser2 : AuthScheme extends "api-key" - ? UserWithType<"ICredential", ICredential> + ? AccessUserCredential : AuthScheme extends "access-token" - ? UserWithType<"IAccessToken", IAccessToken> + ? AccessUserToken : never export const getUserFromRequest = (req: Pick, _schema: S): AuthenticatedUser => { @@ -41,7 +44,7 @@ export const getUserFromRequest = (req: Pick } -async function authCookieSession(req: FastifyRequest): Promise | null> { +async function authCookieSession(req: FastifyRequest): Promise { const token = req.cookies?.[config.auth.session.cookieName] if (!token) { @@ -77,7 +80,7 @@ async function authCookieSession(req: FastifyRequest): Promise | null> { +async function authApiKey(req: FastifyRequest): Promise { const token = req.headers.authorization if (token === null) { @@ -102,7 +105,7 @@ function extractBearerTokenFromHeader(req: FastifyRequest): null | string { return matches === null ? null : matches[1] } -async function authAccessToken(req: FastifyRequest, schema: S): Promise | null> { +async function authAccessToken(req: FastifyRequest, schema: S): Promise { const token = extractBearerTokenFromHeader(req) if (token === null) { return null diff --git a/server/src/security/authorisationService.ts b/server/src/security/authorisationService.ts index 84e1c6a9f5..12d479c8f4 100644 --- a/server/src/security/authorisationService.ts +++ b/server/src/security/authorisationService.ts @@ -1,31 +1,44 @@ import Boom from "boom" import { FastifyRequest } from "fastify" -import { IApplication, ICredential, IJob, IRecruiter, IUserRecruteur } from "shared/models" -import { IUser2 } from "shared/models/user2.model" +import { CFA, ENTREPRISE, OPCOS } from "shared/constants/recruteur" +import { IApplication, IJob, IRecruiter } from "shared/models" +import { ICFA } from "shared/models/cfa.model" +import { IEntreprise } from "shared/models/entreprise.model" +import { AccessEntityType, AccessStatus, IRoleManagement } from "shared/models/roleManagement.model" +import { UserEventType } from "shared/models/user2.model" import { IRouteSchema, WithSecurityScheme } from "shared/routes/common.routes" -import { AccessPermission, AccessResourcePath, AdminRole, CfaRole, OpcoRole, PendingRecruiterRole, RecruiterRole, Role, UserWithType } from "shared/security/permissions" +import { AccessPermission, AccessResourcePath } from "shared/security/permissions" import { assertUnreachable } from "shared/utils" +import { getLastStatusEvent } from "shared/utils/getLastStatusEvent" import { Primitive } from "type-fest" -import { Application, Recruiter, UserRecruteur } from "@/common/model" -import { getUserRecruteurById } from "@/services/userRecruteur.service" - -import { controlUserState } from "../services/login.service" +import { Application, Cfa, Entreprise, Recruiter, RoleManagement, User2 } from "@/common/model" +import { parseEnum } from "@/common/utils/enumUtils" +import { isUserEmailChecked } from "@/services/userRecruteur.service" import { getUserFromRequest } from "./authenticationService" +type RecruiterResource = { recruiter: IRecruiter } & ({ type: "ENTREPRISE"; entreprise: IEntreprise } | { type: "CFA"; cfa: ICFA }) +type JobResource = { job: IJob; recruiterResource: RecruiterResource } +type ApplicationResource = { application: IApplication; jobResource?: JobResource } + type Resources = { - recruiters: Array - jobs: Array<{ job: IJob; recruiter: IRecruiter } | null> - users: Array - applications: Array<{ application: IApplication; job: IJob; recruiter: IRecruiter } | null> + users: Array<{ _id: string }> + recruiters: Array + jobs: Array + applications: Array +} + +type ComputedUserAccess = { + users: string[] + entreprises: string[] + cfas: string[] + opcos: OPCOS[] } // Specify what we need to simplify mocking in tests type IRequest = Pick -type NonTokenUserWithType = UserWithType<"IUser2", IUser2> | UserWithType<"ICredential", ICredential> - // TODO: Unit test access control // TODO: job.delegations // TODO: Unit schema access path properly defined (exists in Zod schema) @@ -35,12 +48,34 @@ function getAccessResourcePathValue(path: AccessResourcePath, req: IRequest): an return obj[path.key] } +const recruiterToRecruiterResource = async (recruiter: IRecruiter): Promise => { + const { cfa_delegated_siret, establishment_id } = recruiter + if (cfa_delegated_siret) { + const cfa = await Cfa.findOne({ siret: cfa_delegated_siret }).lean() + if (!cfa) { + throw Boom.internal(`could not find cfa for recruiter with id=${recruiter._id}`) + } + return { recruiter, type: CFA, cfa } + } else { + const entreprise = await Entreprise.findOne({ establishment_id }).lean() + if (!entreprise) { + throw Boom.internal(`could not find entreprise for recruiter with id=${recruiter._id}`) + } + return { recruiter, type: ENTREPRISE, entreprise } + } +} + +const jobToJobResource = async (job: IJob, recruiter: IRecruiter): Promise => { + const recruiterResource = await recruiterToRecruiterResource(recruiter) + return { job, recruiterResource } +} + async function getRecruitersResource(schema: S, req: IRequest): Promise { if (!schema.securityScheme.resources.recruiter) { return [] } - return ( + const recruiters: IRecruiter[] = ( await Promise.all( schema.securityScheme.resources.recruiter.map(async (recruiterDef): Promise => { if ("_id" in recruiterDef) { @@ -69,6 +104,7 @@ async function getRecruitersResource(schema: S, re }) ) ).flatMap((_) => _) + return (await Promise.all(recruiters.map(recruiterToRecruiterResource))).flatMap((_) => (_ ? [_] : [])) } async function getJobsResource(schema: S, req: IRequest): Promise { @@ -76,28 +112,29 @@ async function getJobsResource(schema: S, req: IRe return [] } - return Promise.all( - schema.securityScheme.resources.job.map(async (j) => { - if ("_id" in j) { - const id = getAccessResourcePathValue(j._id, req) - const recruiter = await Recruiter.findOne({ "jobs._id": id }).lean() + return ( + await Promise.all( + schema.securityScheme.resources.job.map(async (jobDef): Promise => { + if ("_id" in jobDef) { + const id = getAccessResourcePathValue(jobDef._id, req) + const recruiter = await Recruiter.findOne({ "jobs._id": id }).lean() - if (!recruiter) { - return null - } + if (!recruiter) { + return null + } - const job = await recruiter.jobs.find((j) => j._id.toString() === id.toString()) + const job = await recruiter.jobs.find((j) => j._id.toString() === id.toString()) - if (!job) { - return null + if (!job) { + return null + } + return jobToJobResource(job, recruiter) } - return { recruiter, job } - } - - assertUnreachable(j) - }) - ) + assertUnreachable(jobDef) + }) + ) + ).flatMap((_) => (_ ? [_] : [])) } async function getUserResource(schema: S, req: IRequest): Promise { @@ -109,52 +146,47 @@ async function getUserResource(schema: S, req: IRe await Promise.all( schema.securityScheme.resources.user.map(async (userDef) => { if ("_id" in userDef) { - const userOpt = await getUserRecruteurById(getAccessResourcePathValue(userDef._id, req)) - return userOpt ? [userOpt] : [] - } - if ("opco" in userDef) { - return UserRecruteur.find({ opco: getAccessResourcePathValue(userDef.opco, req) }).lean() + const userOpt = await User2.findOne({ _id: getAccessResourcePathValue(userDef._id, req) }).lean() + return userOpt ? { _id: userOpt._id.toString() } : null } - assertUnreachable(userDef) }) ) - ).flatMap((_) => _) + ).flatMap((_) => (_ ? [_] : [])) } -async function getApplicationResouce(schema: S, req: IRequest): Promise { +async function getApplicationResource(schema: S, req: IRequest): Promise { if (!schema.securityScheme.resources.application) { return [] } - return Promise.all( - schema.securityScheme.resources.application.map(async (u) => { - if ("_id" in u) { - const id = getAccessResourcePathValue(u._id, req) + const results: (ApplicationResource | null)[] = await Promise.all( + schema.securityScheme.resources.application.map(async (applicationDef): Promise => { + if ("_id" in applicationDef) { + const id = getAccessResourcePathValue(applicationDef._id, req) const application = await Application.findById(id).lean() - if (!application || !application.job_id) return null - - const jobId = application.job_id - - const recruiter = await Recruiter.findOne({ "jobs._id": jobId }).lean() - + if (!application) return null + const { job_id } = application + if (!job_id) { + return { application } + } + const recruiter = await Recruiter.findOne({ "jobs._id": job_id }).lean() if (!recruiter) { - return null + return { application } } - - const job = recruiter.jobs.find((j) => j._id.toString() === jobId.toString()) - + const job = recruiter.jobs.find((job) => job._id.toString() === job_id) if (!job) { - return null + return { application } } - - return { application, recruiter, job } + const jobResource = await jobToJobResource(job, recruiter) + return { application, jobResource } } - assertUnreachable(u) + assertUnreachable(applicationDef) }) ) + return results.flatMap((_) => (_ ? [_] : [])) } export async function getResources(schema: S, req: IRequest): Promise { @@ -162,7 +194,7 @@ export async function getResources(schema: S, req: getRecruitersResource(schema, req), getJobsResource(schema, req), getUserResource(schema, req), - getApplicationResouce(schema, req), + getApplicationResource(schema, req), ]) return { @@ -173,176 +205,52 @@ export async function getResources(schema: S, req: } } -export function getUserRole(userWithType: NonTokenUserWithType): Role | null { - if (userWithType.type === "ICredential") { - return OpcoRole - } - const userState = controlUserState(userWithType.value.status) - - switch (userWithType.value.type) { - case "ADMIN": - return AdminRole - case "CFA": - return CfaRole - case "ENTREPRISE": - if (userState.error) { - if (userState.reason !== "VALIDATION") throw Boom.internal("Unexpected state during user role validation") - return PendingRecruiterRole - } else { - return RecruiterRole - } - case "OPCO": - return OpcoRole - default: - return assertUnreachable(userWithType.value.type) - } -} - -function canAccessRecruiter(userWithType: NonTokenUserWithType, resource: Resources["recruiters"][number]): boolean { - if (resource === null) { +function canAccessRecruiter(userAccess: ComputedUserAccess, resource: Resources["recruiters"][number]): boolean { + const recruiterOpco = parseEnum(OPCOS, resource.recruiter.opco ?? null) + if (recruiterOpco && userAccess.opcos.includes(recruiterOpco)) { return true } - - if (userWithType.type === "ICredential") { - return resource.opco === userWithType.value.organisation - } - - const user = userWithType.value - switch (user.type) { - case "ADMIN": - return true - case "ENTREPRISE": - return resource.establishment_id === user.establishment_id - case "CFA": - return resource.cfa_delegated_siret === user.establishment_siret - case "OPCO": - return resource.opco === user.scope - default: - assertUnreachable(user.type) + if (resource.type === ENTREPRISE) { + return userAccess.entreprises.includes(resource.entreprise._id.toString()) + } else if (resource.type === CFA) { + return userAccess.cfas.includes(resource.cfa._id.toString()) } + return false } -function canAccessJob(userWithType: NonTokenUserWithType, resource: Resources["jobs"][number]): boolean { - if (resource === null) { - return true - } - - if (userWithType.type === "ICredential") { - return resource.recruiter.opco === userWithType.value.organisation - } - - const user = userWithType.value - switch (user.type) { - case "ADMIN": - return true - case "ENTREPRISE": - return resource.recruiter.establishment_id === user.establishment_id - case "CFA": - return resource.recruiter.cfa_delegated_siret === user.establishment_siret - case "OPCO": - return resource.recruiter.opco === user.scope - default: - assertUnreachable(user.type) - } +function canAccessJob(userAccess: ComputedUserAccess, resource: Resources["jobs"][number]): boolean { + return canAccessRecruiter(userAccess, resource.recruiterResource) } -function canAccessUser(userWithType: NonTokenUserWithType, resource: Resources["users"][number]): boolean { - if (resource === null) { +function canAccessUser(userAccess: ComputedUserAccess, resource: Resources["users"][number]): boolean { + if (userAccess.opcos.length) { return true } - - if (userWithType.type === "ICredential") { - return resource.type === "OPCO" && resource.scope === userWithType.value.organisation - } - - if (resource._id.toString() === userWithType.value._id.toString()) { - return true - } - - const user = userWithType.value - switch (user.type) { - case "ADMIN": - return true - case "ENTREPRISE": - return resource._id.toString() === user._id.toString() - case "CFA": - return resource._id.toString() === user._id.toString() - case "OPCO": - return (resource.type === "OPCO" && resource._id === user._id) || (resource.type === "ENTREPRISE" && resource.opco === user.scope) - default: - assertUnreachable(user.type) - } + return userAccess.users.includes(resource._id) } -function canAccessApplication(userWithType: NonTokenUserWithType, resource: Resources["applications"][number]): boolean { - if (resource === null) { - return true - } - - if (userWithType.type === "ICredential") { - return false - } - - const user = userWithType.value - switch (user.type) { - case "ADMIN": - return true - case "ENTREPRISE": { - if (resource.application.job_origin === "matcha") { - return resource.recruiter.establishment_id === userWithType.value.establishment_id - } - - return false - } - case "CFA": - return false - case "OPCO": - return false - default: - assertUnreachable(user.type) - } +function canAccessApplication(userAccess: ComputedUserAccess, resource: Resources["applications"][number]): boolean { + const { application, jobResource } = resource + // TODO ajout de granularité pour les accès candidat et recruteur + return (jobResource && canAccessJob(userAccess, jobResource)) || canAccessUser(userAccess, { _id: application.applicant_id.toString() }) } -export function isAuthorized(access: AccessPermission, userWithType: NonTokenUserWithType, role: Role | null, resources: Resources): boolean { +export function isAuthorized(access: AccessPermission, userAccess: ComputedUserAccess, resources: Resources): boolean { if (typeof access === "object") { if ("some" in access) { - return access.some.some((a) => isAuthorized(a, userWithType, role, resources)) - } - - if ("every" in access) { - return access.every.every((a) => isAuthorized(a, userWithType, role, resources)) - } - - assertUnreachable(access) - } - - // Role is null for access token but we have permission - if (role && !role.permissions.includes(access)) { - return false - } - - switch (access) { - case "recruiter:manage": - case "recruiter:add_job": - return resources.recruiters.every((recruiter) => canAccessRecruiter(userWithType, recruiter)) - - case "job:manage": - return resources.jobs.every((job) => canAccessJob(userWithType, job)) - - case "school:manage": - // School is actually the UserRecruteur - return resources.users.every((user) => canAccessUser(userWithType, user)) - case "application:manage": - return resources.applications.every((application) => canAccessApplication(userWithType, application)) - case "user:validate": - case "user:manage": - return resources.users.every((user) => canAccessUser(userWithType, user)) - case "admin": - // Admin should already have been approved, otherwise you cannot access to admin - return false - default: + return access.some.some((permission) => isAuthorized(permission, userAccess, resources)) + } else if ("every" in access) { + return access.every.every((permission) => isAuthorized(permission, userAccess, resources)) + } else { assertUnreachable(access) + } } + return ( + resources.recruiters.every((recruiter) => canAccessRecruiter(userAccess, recruiter)) && + resources.jobs.every((job) => canAccessJob(userAccess, job)) && + resources.applications.every((application) => canAccessApplication(userAccess, application)) && + resources.users.every((user) => canAccessUser(userAccess, user)) + ) } export async function authorizationMiddleware & WithSecurityScheme>(schema: S, req: IRequest) { @@ -355,19 +263,60 @@ export async function authorizationMiddleware role.authorized_type === AccessEntityType.ADMIN) + if (isAdmin) { + return + } + if (!grantedRoles.length) { + throw Boom.forbidden("aucun role") + } + } const resources = await getResources(schema, req) - const role = getUserRole(userWithType) - if (!isAuthorized(schema.securityScheme.access, userWithType, role, resources)) { - throw Boom.forbidden() + if (userType === "ICredential") { + const { organisation } = userWithType.value + const opco = parseEnum(OPCOS, organisation) + const userAccess: ComputedUserAccess = { + users: [], + cfas: [], + entreprises: [], + opcos: opco ? [opco] : [], + } + if (!isAuthorized(schema.securityScheme.access, userAccess, resources)) { + throw Boom.forbidden() + } + } else if (userType === "IUser2") { + const { _id } = userWithType.value + // TODO + // const indirectUserRoles = await RoleManagement.find({ }) + const userAccess: ComputedUserAccess = { + users: [_id.toString()], + cfas: grantedRoles.flatMap((role) => (role.authorized_type === AccessEntityType.CFA ? [role.authorized_id] : [])), + entreprises: grantedRoles.flatMap((role) => (role.authorized_type === AccessEntityType.ENTREPRISE ? [role.authorized_id] : [])), + opcos: [], + } + if (!isAuthorized(schema.securityScheme.access, userAccess, resources)) { + throw Boom.forbidden() + } + } else { + assertUnreachable(userType) } } diff --git a/server/tests/unit/security/authorisationService.test.ts b/server/tests/unit/security/authorisationService.test.ts index 3158bae9a5..f5c6d26b9c 100644 --- a/server/tests/unit/security/authorisationService.test.ts +++ b/server/tests/unit/security/authorisationService.test.ts @@ -2,33 +2,35 @@ import { FastifyRequest } from "fastify" import { ObjectId } from "mongodb" import { ETAT_UTILISATEUR } from "shared/constants/recruteur" import { IApplication, ICredential, IJob, IRecruiter, IUserRecruteur } from "shared/models" +import { IUser2 } from "shared/models/user2.model" import { SecurityScheme } from "shared/routes/common.routes" -import { AccessPermission, AccessRessouces, Permission, UserWithType } from "shared/security/permissions" +import { AccessPermission, AccessRessouces, Permission } from "shared/security/permissions" import { describe, expect, it } from "vitest" import { Application, Recruiter, UserRecruteur } from "@/common/model" -import { IAccessToken, generateScope } from "@/security/accessTokenService" +import { generateScope } from "@/security/accessTokenService" +import { AccessUserToken } from "@/security/authenticationService" import { authorizationMiddleware } from "@/security/authorisationService" import { useMongo } from "@tests/utils/mongo.utils" import { createApplicationTest, createCredentialTest, createRecruteurTest, createUserRecruteurTest } from "../../utils/user.utils" describe("authorisationService", () => { - let adminUser: IUserRecruteur - let opcoUserO1U1: IUserRecruteur - let opcoUserO1U2: IUserRecruteur - let cfaUser1: IUserRecruteur - let cfaUser2: IUserRecruteur - let recruteurUserO1E1R1: IUserRecruteur + let adminUser: IUser2 + let opcoUserO1U1: IUser2 + let opcoUserO1U2: IUser2 + let cfaUser1: IUser2 + let cfaUser2: IUser2 + let recruteurUserO1E1R1: IUser2 let recruteurO1E1R1: IRecruiter - let recruteurUserO1E1R2: IUserRecruteur + let recruteurUserO1E1R2: IUser2 let recruteurO1E1R2: IRecruiter - let recruteurUserO1E2R1: IUserRecruteur + let recruteurUserO1E2R1: IUser2 let recruteurO1E2R1: IRecruiter - let opcoUserO2U1: IUserRecruteur - let recruteurUserO2E1R1: IUserRecruteur + let opcoUserO2U1: IUser2 + let recruteurUserO2E1R1: IUser2 let recruteurO2E1R1: IRecruiter - let recruteurUserO2E1R1P: IUserRecruteur + let recruteurUserO2E1R1P: IUser2 let recruteurO2E1R1P: IRecruiter let credentialO1: ICredential let applicationO1E1R1J1A1: IApplication @@ -325,7 +327,7 @@ describe("authorisationService", () => { }, { user: { - type: "IUserRecruteur", + type: "IUser2", value: adminUser, }, ...req, @@ -349,7 +351,7 @@ describe("authorisationService", () => { }, { user: { - type: "IUserRecruteur", + type: "IUser2", value: opcoUserO1U1, }, ...req, @@ -370,7 +372,7 @@ describe("authorisationService", () => { }, { user: { - type: "IUserRecruteur", + type: "IUser2", value: opcoUserO1U1, }, ...req, @@ -391,7 +393,7 @@ describe("authorisationService", () => { }, { user: { - type: "IUserRecruteur", + type: "IUser2", value: opcoUserO1U1, }, ...req, @@ -412,7 +414,7 @@ describe("authorisationService", () => { }, { user: { - type: "IUserRecruteur", + type: "IUser2", value: opcoUserO1U1, }, ...req, @@ -434,7 +436,7 @@ describe("authorisationService", () => { }, { user: { - type: "IUserRecruteur", + type: "IUser2", value: opcoUserO1U1, }, ...req, @@ -455,7 +457,7 @@ describe("authorisationService", () => { }, { user: { - type: "IUserRecruteur", + type: "IUser2", value: opcoUserO1U1, }, ...req, @@ -477,7 +479,7 @@ describe("authorisationService", () => { }, { user: { - type: "IUserRecruteur", + type: "IUser2", value: opcoUserO1U1, }, ...req, @@ -499,7 +501,7 @@ describe("authorisationService", () => { }, { user: { - type: "IUserRecruteur", + type: "IUser2", value: opcoUserO1U1, }, ...req, @@ -521,7 +523,7 @@ describe("authorisationService", () => { }, { user: { - type: "IUserRecruteur", + type: "IUser2", value: opcoUserO1U1, }, ...req, @@ -543,7 +545,7 @@ describe("authorisationService", () => { }, { user: { - type: "IUserRecruteur", + type: "IUser2", value: opcoUserO1U1, }, ...req, @@ -565,7 +567,7 @@ describe("authorisationService", () => { }, { user: { - type: "IUserRecruteur", + type: "IUser2", value: opcoUserO1U1, }, ...req, @@ -587,7 +589,7 @@ describe("authorisationService", () => { }, { user: { - type: "IUserRecruteur", + type: "IUser2", value: opcoUserO1U1, }, ...req, @@ -874,7 +876,7 @@ describe("authorisationService", () => { }, { user: { - type: "IUserRecruteur", + type: "IUser2", value: cfaUser1, }, ...req, @@ -895,7 +897,7 @@ describe("authorisationService", () => { }, { user: { - type: "IUserRecruteur", + type: "IUser2", value: cfaUser1, }, ...req, @@ -916,7 +918,7 @@ describe("authorisationService", () => { }, { user: { - type: "IUserRecruteur", + type: "IUser2", value: cfaUser1, }, ...req, @@ -938,7 +940,7 @@ describe("authorisationService", () => { }, { user: { - type: "IUserRecruteur", + type: "IUser2", value: cfaUser1, }, ...req, @@ -960,7 +962,7 @@ describe("authorisationService", () => { }, { user: { - type: "IUserRecruteur", + type: "IUser2", value: cfaUser1, }, ...req, @@ -981,7 +983,7 @@ describe("authorisationService", () => { }, { user: { - type: "IUserRecruteur", + type: "IUser2", value: cfaUser1, }, ...req, @@ -1003,7 +1005,7 @@ describe("authorisationService", () => { }, { user: { - type: "IUserRecruteur", + type: "IUser2", value: cfaUser1, }, ...req, @@ -1025,7 +1027,7 @@ describe("authorisationService", () => { }, { user: { - type: "IUserRecruteur", + type: "IUser2", value: cfaUser1, }, ...req, @@ -1047,7 +1049,7 @@ describe("authorisationService", () => { }, { user: { - type: "IUserRecruteur", + type: "IUser2", value: cfaUser1, }, ...req, @@ -1069,7 +1071,7 @@ describe("authorisationService", () => { }, { user: { - type: "IUserRecruteur", + type: "IUser2", value: cfaUser1, }, ...req, @@ -1091,7 +1093,7 @@ describe("authorisationService", () => { }, { user: { - type: "IUserRecruteur", + type: "IUser2", value: cfaUser1, }, ...req, @@ -1115,7 +1117,7 @@ describe("authorisationService", () => { }, { user: { - type: "IUserRecruteur", + type: "IUser2", value: recruteurUserO1E1R1, }, ...req, @@ -1136,7 +1138,7 @@ describe("authorisationService", () => { }, { user: { - type: "IUserRecruteur", + type: "IUser2", value: recruteurUserO1E1R1, }, ...req, @@ -1157,7 +1159,7 @@ describe("authorisationService", () => { }, { user: { - type: "IUserRecruteur", + type: "IUser2", value: recruteurUserO1E1R1, }, ...req, @@ -1178,7 +1180,7 @@ describe("authorisationService", () => { }, { user: { - type: "IUserRecruteur", + type: "IUser2", value: recruteurUserO1E1R1, }, ...req, @@ -1199,7 +1201,7 @@ describe("authorisationService", () => { }, { user: { - type: "IUserRecruteur", + type: "IUser2", value: recruteurUserO1E1R1, }, ...req, @@ -1220,7 +1222,7 @@ describe("authorisationService", () => { }, { user: { - type: "IUserRecruteur", + type: "IUser2", value: recruteurUserO1E1R1, }, ...req, @@ -1241,7 +1243,7 @@ describe("authorisationService", () => { }, { user: { - type: "IUserRecruteur", + type: "IUser2", value: recruteurUserO1E1R1, }, ...req, @@ -1262,7 +1264,7 @@ describe("authorisationService", () => { }, { user: { - type: "IUserRecruteur", + type: "IUser2", value: recruteurUserO1E1R1, }, ...req, @@ -1283,7 +1285,7 @@ describe("authorisationService", () => { }, { user: { - type: "IUserRecruteur", + type: "IUser2", value: recruteurUserO1E1R1, }, ...req, @@ -1304,7 +1306,7 @@ describe("authorisationService", () => { }, { user: { - type: "IUserRecruteur", + type: "IUser2", value: recruteurUserO1E1R1, }, ...req, @@ -1325,7 +1327,7 @@ describe("authorisationService", () => { }, { user: { - type: "IUserRecruteur", + type: "IUser2", value: recruteurUserO1E1R1, }, ...req, @@ -1346,7 +1348,7 @@ describe("authorisationService", () => { }, { user: { - type: "IUserRecruteur", + type: "IUser2", value: recruteurUserO1E1R1, }, ...req, @@ -1369,7 +1371,7 @@ describe("authorisationService", () => { }, { user: { - type: "IUserRecruteur", + type: "IUser2", value: recruteurUserO2E1R1P, }, ...req, @@ -1390,7 +1392,7 @@ describe("authorisationService", () => { }, { user: { - type: "IUserRecruteur", + type: "IUser2", value: recruteurUserO2E1R1P, }, ...req, @@ -1411,7 +1413,7 @@ describe("authorisationService", () => { }, { user: { - type: "IUserRecruteur", + type: "IUser2", value: recruteurUserO2E1R1P, }, ...req, @@ -1432,7 +1434,7 @@ describe("authorisationService", () => { }, { user: { - type: "IUserRecruteur", + type: "IUser2", value: recruteurUserO2E1R1P, }, ...req, @@ -1453,7 +1455,7 @@ describe("authorisationService", () => { }, { user: { - type: "IUserRecruteur", + type: "IUser2", value: recruteurUserO2E1R1P, }, ...req, @@ -1474,7 +1476,7 @@ describe("authorisationService", () => { }, { user: { - type: "IUserRecruteur", + type: "IUser2", value: recruteurUserO2E1R1P, }, ...req, @@ -1495,7 +1497,7 @@ describe("authorisationService", () => { }, { user: { - type: "IUserRecruteur", + type: "IUser2", value: recruteurUserO2E1R1P, }, ...req, @@ -1516,7 +1518,7 @@ describe("authorisationService", () => { }, { user: { - type: "IUserRecruteur", + type: "IUser2", value: recruteurUserO2E1R1P, }, ...req, @@ -1537,7 +1539,7 @@ describe("authorisationService", () => { }, { user: { - type: "IUserRecruteur", + type: "IUser2", value: recruteurUserO2E1R1P, }, ...req, @@ -1578,7 +1580,7 @@ describe("authorisationService", () => { }, { user: { - type: "IUserRecruteur", + type: "IUser2", value: recruteurUserO1E1R1, }, query, @@ -1595,7 +1597,7 @@ describe("authorisationService", () => { }, { user: { - type: "IUserRecruteur", + type: "IUser2", value: recruteurUserO1E1R2, }, query, @@ -1634,7 +1636,7 @@ describe("authorisationService", () => { }, { user: { - type: "IUserRecruteur", + type: "IUser2", value: recruteurUserO1E1R1, }, query, @@ -1651,7 +1653,7 @@ describe("authorisationService", () => { }, { user: { - type: "IUserRecruteur", + type: "IUser2", value: recruteurUserO1E1R2, }, query, @@ -1690,7 +1692,7 @@ describe("authorisationService", () => { }, { user: { - type: "IUserRecruteur", + type: "IUser2", value: opcoUserO1U1, }, query, @@ -1707,7 +1709,7 @@ describe("authorisationService", () => { }, { user: { - type: "IUserRecruteur", + type: "IUser2", value: recruteurUserO1E1R1, }, query, @@ -1733,7 +1735,7 @@ describe("authorisationService", () => { }, { user: { - type: "IUserRecruteur", + type: "IUser2", value: opcoUserO1U1, }, query: {}, @@ -1753,7 +1755,7 @@ describe("authorisationService", () => { recruiter: [{ _id: { type: "params", key: "id" } }], }, } - const userWithType: UserWithType<"IAccessToken", IAccessToken> = { + const userWithType: AccessUserToken = { type: "IAccessToken", value: { identity: { type: "cfa", email: "mail@mail.com", siret: "55327987900672" }, @@ -1813,7 +1815,7 @@ describe("authorisationService", () => { job: [{ _id: { type: "params", key: "id" } }], }, } - const userWithType: UserWithType<"IAccessToken", IAccessToken> = { + const userWithType: AccessUserToken = { type: "IAccessToken", value: { identity: { type: "cfa", email: "mail@mail.com", siret: "55327987900672" }, @@ -1873,7 +1875,7 @@ describe("authorisationService", () => { application: [{ _id: { type: "params", key: "id" } }], }, } - const userWithType: UserWithType<"IAccessToken", IAccessToken> = { + const userWithType: AccessUserToken = { type: "IAccessToken", value: { identity: { type: "cfa", email: "mail@mail.com", siret: "55327987900672" }, @@ -1933,7 +1935,7 @@ describe("authorisationService", () => { user: [{ _id: { type: "params", key: "id" } }], }, } - const userWithType: UserWithType<"IAccessToken", IAccessToken> = { + const userWithType: AccessUserToken = { type: "IAccessToken", value: { identity: { type: "cfa", email: "mail@mail.com", siret: "55327987900672" }, diff --git a/shared/models/applications.model.ts b/shared/models/applications.model.ts index ffc5cbbe8b..9c57faed4d 100644 --- a/shared/models/applications.model.ts +++ b/shared/models/applications.model.ts @@ -8,6 +8,7 @@ import { zObjectId } from "./common" export const ZApplication = z .object({ _id: zObjectId, + applicant_id: zObjectId, applicant_email: z.string().email().openapi({ description: "L'adresse email du candidat à laquelle l'entreprise contactée pourra répondre. Les adresses emails temporaires ne sont pas acceptées.", example: "john.smith@mail.com", diff --git a/shared/routes/user.routes.ts b/shared/routes/user.routes.ts index 3d049940e4..e4cac7b7ef 100644 --- a/shared/routes/user.routes.ts +++ b/shared/routes/user.routes.ts @@ -47,7 +47,6 @@ export const zUserRecruteurRoutes = { auth: "cookie-session", access: { every: ["user:manage", "recruiter:manage"] }, resources: { - user: [{ opco: { type: "query", key: "opco" } }], recruiter: [{ opco: { type: "query", key: "opco" } }], }, }, diff --git a/shared/security/permissions.ts b/shared/security/permissions.ts index 208143986f..814ebc5a7c 100644 --- a/shared/security/permissions.ts +++ b/shared/security/permissions.ts @@ -70,14 +70,9 @@ export type AccessRessouces = { application?: ReadonlyArray<{ _id: AccessResourcePath }> - user?: ReadonlyArray< - | { - _id: AccessResourcePath - } - | { - opco: AccessResourcePath - } - > + user?: ReadonlyArray<{ + _id: AccessResourcePath + }> } export type UserWithType = Readonly<{ diff --git a/ui/common/hooks/useUserHistoryUpdate.ts b/ui/common/hooks/useUserHistoryUpdate.ts index a5d56224e8..04ba5d0ac5 100644 --- a/ui/common/hooks/useUserHistoryUpdate.ts +++ b/ui/common/hooks/useUserHistoryUpdate.ts @@ -1,20 +1,15 @@ import { useToast } from "@chakra-ui/react" import { useCallback } from "react" import { useQueryClient } from "react-query" -import { ETAT_UTILISATEUR, VALIDATION_UTILISATEUR } from "shared/constants/recruteur" import { updateUserValidationHistory } from "../../utils/api" -export default function useUserHistoryUpdate(userId: string, status: ETAT_UTILISATEUR, reason?: string) { +export default function useUserHistoryUpdate(props: Parameters[0]) { const client = useQueryClient() const toast = useToast() return useCallback(async () => { - await updateUserValidationHistory(userId, { - validation_type: VALIDATION_UTILISATEUR.MANUAL, - status, - reason, - }) + await updateUserValidationHistory(props) .then(() => ["user-list-opco", "user-list", "user"].map((x) => client.invalidateQueries(x))) .then(() => toast({ @@ -25,5 +20,5 @@ export default function useUserHistoryUpdate(userId: string, status: ETAT_UTILIS isClosable: true, }) ) - }, [client, reason, status, toast, userId]) + }, [client, toast, ...Object.values(props)]) } diff --git a/ui/pages/espace-pro/administration/users/[userId].tsx b/ui/pages/espace-pro/administration/users/[userId].tsx index afcf619c77..971b470281 100644 --- a/ui/pages/espace-pro/administration/users/[userId].tsx +++ b/ui/pages/espace-pro/administration/users/[userId].tsx @@ -23,6 +23,7 @@ import { useRouter } from "next/router" import { useMutation, useQuery, useQueryClient } from "react-query" import { IUserStatusValidation } from "shared" import { ETAT_UTILISATEUR } from "shared/constants/recruteur" +import { AccessStatus } from "shared/models/roleManagement.model" import * as Yup from "yup" import { getAuthServerSideProps } from "@/common/SSR/getAuthServerSideProps" @@ -56,7 +57,7 @@ function DetailEntreprise() { const { user } = useAuth() const ActivateUserButton = ({ userId }) => { - const updateUserHistory = useUserHistoryUpdate(userId, ETAT_UTILISATEUR.VALIDE) + const updateUserHistory = useUserHistoryUpdate(userId, AccessStatus.GRANTED) return ( ) diff --git a/ui/pages/espace-pro/administration/users/[userId].tsx b/ui/pages/espace-pro/administration/users/[userId].tsx index 971b470281..64a0894fa8 100644 --- a/ui/pages/espace-pro/administration/users/[userId].tsx +++ b/ui/pages/espace-pro/administration/users/[userId].tsx @@ -23,14 +23,13 @@ import { useRouter } from "next/router" import { useMutation, useQuery, useQueryClient } from "react-query" import { IUserStatusValidation } from "shared" import { ETAT_UTILISATEUR } from "shared/constants/recruteur" -import { AccessStatus } from "shared/models/roleManagement.model" import * as Yup from "yup" +import { useUserPermissionsActions } from "@/common/hooks/useUserPermissionsActions" import { getAuthServerSideProps } from "@/common/SSR/getAuthServerSideProps" import { useAuth } from "@/context/UserContext" import { AUTHTYPE } from "../../../../common/contants" -import useUserHistoryUpdate from "../../../../common/hooks/useUserHistoryUpdate" import { AnimationContainer, ConfirmationDesactivationUtilisateur, @@ -57,10 +56,10 @@ function DetailEntreprise() { const { user } = useAuth() const ActivateUserButton = ({ userId }) => { - const updateUserHistory = useUserHistoryUpdate(userId, AccessStatus.GRANTED) + const { activate } = useUserPermissionsActions(userId, organizationId, organizationType) return ( - ) diff --git a/ui/utils/api.ts b/ui/utils/api.ts index 2f54e23cb9..0315c03835 100644 --- a/ui/utils/api.ts +++ b/ui/utils/api.ts @@ -55,7 +55,7 @@ export const createEtablissementDelegationByToken = ({ data, jobId, token }: { j /** * User API */ -export const getUser = (userId: string, entrepriseId: string) => apiGet("/user/:userId/organization/:organizationId", { params: { userId, entrepriseId } }) +export const getUser = (userId: string, organizationId: string) => apiGet("/user/:userId/organization/:organizationId", { params: { userId, organizationId } }) const updateUser = (userId: string, user) => apiPut("/user/:userId", { params: { userId }, body: user }) const updateUserAdmin = (userId: string, user) => apiPut("/admin/users/:userId", { params: { userId }, body: user }) export const getUserStatus = (userId: string) => apiGet("/user/status/:userId", { params: { userId } }) From 730bec80e5a9adace7eeef5dca886ed8edcace1a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9my=20Auricoste?= Date: Wed, 6 Mar 2024 16:05:44 +0100 Subject: [PATCH 30/78] fix: refactor de UserForm --- .../Admin/utilisateurs/UserForm.tsx | 77 ++++++++++--------- .../admin/utilisateurs/[userId]/index.tsx | 14 ++-- 2 files changed, 45 insertions(+), 46 deletions(-) diff --git a/ui/components/espace_pro/Admin/utilisateurs/UserForm.tsx b/ui/components/espace_pro/Admin/utilisateurs/UserForm.tsx index b39d842a4d..d0cd0cd046 100644 --- a/ui/components/espace_pro/Admin/utilisateurs/UserForm.tsx +++ b/ui/components/espace_pro/Admin/utilisateurs/UserForm.tsx @@ -1,6 +1,8 @@ import { Box, Button, Checkbox, FormControl, FormErrorMessage, FormLabel, HStack, Input, VStack, useDisclosure, useToast } from "@chakra-ui/react" import { useFormik } from "formik" -import { ETAT_UTILISATEUR } from "shared/constants/recruteur" +import { IUserRecruteurJson } from "shared" +import { AccessStatus } from "shared/models/roleManagement.model" +import { getLastStatusEvent } from "shared/utils/getLastStatusEvent" import * as Yup from "yup" import { useUserPermissionsActions } from "@/common/hooks/useUserPermissionsActions" @@ -9,52 +11,45 @@ import { apiDelete, apiPut } from "@/utils/api.utils" import ConfirmationDesactivationUtilisateur from "../../ConfirmationDesactivationUtilisateur" -const ActivateUserButton = ({ userId, onUpdate }) => { - const { activate } = useUserPermissionsActions(userId, organizationId, organizationType) - +const ActivateUserButton = ({ onClick }) => { return ( - ) } -const DisableUserButton = ({ confirmationDesactivationUtilisateur }) => { +const DisableUserButton = ({ onClick }) => { return ( - ) } -const getActionButtons = (userHistory, userId, confirmationDesactivationUtilisateur, onUpdate) => { - switch (userHistory?.status) { - case ETAT_UTILISATEUR.ATTENTE: - return ( - <> - - - - ) - case ETAT_UTILISATEUR.VALIDE: - return - case ETAT_UTILISATEUR.DESACTIVE: - return - - default: - return <> - } +const ActionButtons = ({ currentStatus, onDisableUser, onActivateUser }: { currentStatus: AccessStatus; onDisableUser: () => void; onActivateUser: () => void }) => { + return ( + <> + {currentStatus !== AccessStatus.GRANTED && } + {currentStatus !== AccessStatus.DENIED && } + + ) } -const UserForm = ({ user, onCreate, onDelete, onUpdate }: { user: any; onCreate?: (result: void, error?: any) => void; onDelete?: () => void; onUpdate?: () => void }) => { +const UserForm = ({ + user, + onCreate, + onDelete, + onUpdate, +}: { + user: IUserRecruteurJson + onCreate?: (result: void, error?: any) => void + onDelete?: () => void + onUpdate?: () => void +}) => { const toast = useToast() const confirmationDesactivationUtilisateur = useDisclosure() + const { activate: activateUser } = useUserPermissionsActions(user._id.toString(), organizationId, user.type) const { values, errors, touched, dirty, handleSubmit, handleChange } = useFormik({ initialValues: { last_name: user?.last_name || "", @@ -86,7 +81,7 @@ const UserForm = ({ user, onCreate, onDelete, onUpdate }: { user: any; onCreate? try { if (user) { result = await apiPut("/admin/users/:userId", { - params: { userId: user._id }, + params: { userId: user._id.toString() }, body: { ...values, type: beAdmin ? "ADMIN" : values.type, @@ -154,8 +149,8 @@ const UserForm = ({ user, onCreate, onDelete, onUpdate }: { user: any; onCreate? }, }) - const onDeleteClicked = async (e) => { - e.preventDefault() + const onDeleteClicked = async (event) => { + event.preventDefault() if (confirm("Voulez-vous vraiment supprimer cet utilisateur ?")) { const result = (await apiDelete("/admin/users/:userId", { params: { userId: user._id as string }, querystring: {} })) as any if (result?.ok) { @@ -177,7 +172,7 @@ const UserForm = ({ user, onCreate, onDelete, onUpdate }: { user: any; onCreate? } } - const [lastUserState] = user?.status.slice(-1) || "" + const accessStatus = getLastStatusEvent(user?.status ?? [])?.status return ( <> @@ -191,7 +186,15 @@ const UserForm = ({ user, onCreate, onDelete, onUpdate }: { user: any; onCreate? Statut du compte = - {lastUserState?.status} {getActionButtons(lastUserState, user._id, confirmationDesactivationUtilisateur, onUpdate)}{" "} + {accessStatus} + confirmationDesactivationUtilisateur.onOpen()} + onActivateUser={() => { + activateUser() + onUpdate?.() + }} + />{" "} + + + + + )} + +
+ + {user && ( + + Identifiant + + + )} + + + + + + + + +
+
+ + ) +} + +const ActivateUserButton = ({ onClick }) => { + return ( + + ) +} + +const DisableUserButton = ({ onClick }) => { + return ( + + ) +} diff --git a/ui/components/espace_pro/Admin/utilisateurs/AdminUserList.tsx b/ui/components/espace_pro/Admin/utilisateurs/AdminUserList.tsx index 8a91f2ec34..2ce1ad51ad 100644 --- a/ui/components/espace_pro/Admin/utilisateurs/AdminUserList.tsx +++ b/ui/components/espace_pro/Admin/utilisateurs/AdminUserList.tsx @@ -11,7 +11,7 @@ import { apiGet } from "../../../../utils/api.utils" import LoadingEmptySpace from "../../LoadingEmptySpace" import TableNew from "../../TableNew" -import UserForm from "./UserForm" +import { AdminUserForm } from "./AdminUserForm" const AdminUserList = () => { const newUser = useDisclosure() @@ -46,8 +46,9 @@ const AdminUserList = () => { fermer - { if (!error) { newUser.onClose() diff --git a/ui/components/espace_pro/Admin/utilisateurs/UserForm.tsx b/ui/components/espace_pro/Admin/utilisateurs/UserForm.tsx deleted file mode 100644 index 9fd1f11938..0000000000 --- a/ui/components/espace_pro/Admin/utilisateurs/UserForm.tsx +++ /dev/null @@ -1,287 +0,0 @@ -import { Box, Button, Checkbox, FormControl, FormErrorMessage, FormLabel, HStack, Input, VStack, useDisclosure, useToast } from "@chakra-ui/react" -import { useFormik } from "formik" -import { IUserRecruteurJson } from "shared" -import { AccessStatus } from "shared/models/roleManagement.model" -import { getLastStatusEvent } from "shared/utils/getLastStatusEvent" -import * as Yup from "yup" - -import { useUserPermissionsActions } from "@/common/hooks/useUserPermissionsActions" -import { createUser } from "@/utils/api" -import { apiDelete, apiPut } from "@/utils/api.utils" - -import ConfirmationDesactivationUtilisateur from "../../ConfirmationDesactivationUtilisateur" - -const ActivateUserButton = ({ onClick }) => { - return ( - - ) -} - -const DisableUserButton = ({ onClick }) => { - return ( - - ) -} - -const ActionButtons = ({ currentStatus, onDisableUser, onActivateUser }: { currentStatus: AccessStatus; onDisableUser: () => void; onActivateUser: () => void }) => { - return ( - <> - {currentStatus !== AccessStatus.GRANTED && } - {currentStatus !== AccessStatus.DENIED && } - - ) -} - -const UserForm = ({ - user, - onCreate, - onDelete, - onUpdate, -}: { - user: IUserRecruteurJson - onCreate?: (result: void, error?: any) => void - onDelete?: () => void - onUpdate?: () => void -}) => { - const toast = useToast() - const confirmationDesactivationUtilisateur = useDisclosure() - const { activate: activateUser } = useUserPermissionsActions(user._id.toString()) - const { values, errors, touched, dirty, handleSubmit, handleChange } = useFormik({ - initialValues: { - last_name: user?.last_name || "", - first_name: user?.first_name || "", - email: user?.email || "", - phone: user?.phone || "Non renseigné", - beAdmin: user?.type === "ADMIN" || false, - type: user?.type || "", - scope: user?.scope || "all", - establishment_siret: user?.establishment_siret || "13002526500013", - establishment_raison_sociale: user?.establishment_raison_sociale || "beta.gouv (Dinum)", - }, - validationSchema: Yup.object().shape({ - last_name: Yup.string().required("Votre nom est obligatoire"), - first_name: Yup.string().required("Votre prénom est obligatoire"), - email: Yup.string().email("Format d'email invalide").required("Votre email est obligatoire"), - phone: Yup.string(), - beAdmin: Yup.boolean().required("Vous devez cocher cette case"), - type: Yup.string(), - scope: Yup.string().required("obligatoire"), - establishment_siret: Yup.string().required("obligatoire"), - establishment_raison_sociale: Yup.string().required("obligatoire"), - }), - enableReinitialize: true, - onSubmit: async ({ beAdmin, ...values }, { setSubmitting }) => { - let result - let error - - try { - if (user) { - result = await apiPut("/admin/users/:userId", { - params: { userId: user._id.toString() }, - body: { - ...values, - // @ts-expect-error - type: beAdmin ? "ADMIN" : values.type, - }, - }) - if (result?.ok) { - toast({ - title: "Utilisateur mis à jour", - status: "success", - isClosable: true, - }) - } else { - toast({ - title: "Erreur lors de la mise à jour de l'utilisateur.", - status: "error", - isClosable: true, - description: " Merci de réessayer plus tard", - }) - } - onUpdate?.() - } else { - result = await createUser({ - ...values, - // @ts-expect-error - type: beAdmin ? "ADMIN" : values.type, - }).catch((err) => { - if (err.statusCode === 409) { - return { error: "Cet utilisateur existe déjà" } - } - }) - if (result?._id) { - toast({ - title: "Utilisateur créé", - status: "success", - isClosable: true, - }) - onCreate?.(result) - } else if (result?.error) { - error = toast({ - title: result.error, - status: "error", - isClosable: true, - }) - } else { - error = "Erreur lors de la création de l'utilisateur." - toast({ - title: error, - status: "error", - isClosable: true, - description: " Merci de réessayer plus tard", - }) - } - } - } catch (e) { - error = e - console.error(e) - const response = await (e?.json ?? {}) - const message = response?.message ?? e?.message - toast({ - title: message, - status: "error", - isClosable: true, - }) - } - setSubmitting(false) - }, - }) - - const onDeleteClicked = async (event) => { - event.preventDefault() - if (confirm("Voulez-vous vraiment supprimer cet utilisateur ?")) { - const result = (await apiDelete("/admin/users/:userId", { params: { userId: user._id as string }, querystring: {} })) as any - if (result?.ok) { - toast({ - title: "Utilisateur supprimé", - status: "success", - isClosable: true, - }) - } else { - toast({ - title: "Erreur lors de la suppression de l'utilisateur.", - status: "error", - isClosable: true, - description: " Merci de réessayer plus tard", - }) - } - - return onDelete?.() - } - } - - const accessStatus = getLastStatusEvent(user?.status ?? [])?.status - - return ( - <> - - {user && ( - <> - - Type de compte - {user.type} - - - Statut du compte = - - {accessStatus} - confirmationDesactivationUtilisateur.onOpen()} - onActivateUser={() => { - activateUser() - onUpdate?.() - }} - />{" "} - - - - - - - )} -
- - {user && ( - - Identifiant - - - )} - - Prénom - - {errors.first_name && touched.first_name && {errors.first_name as string}} - - - Nom - - {errors.last_name && touched.last_name && {errors.last_name as string}} - - - Email - - {errors.email && touched.email && {errors.email as string}} - - - Téléphone - - {errors.phone && touched.phone && {errors.phone as string}} - - - Siret - - {errors.establishment_siret && touched.establishment_siret && {errors.establishment_siret as string}} - - - Raison sociale - - {errors.establishment_raison_sociale && touched.establishment_raison_sociale && {errors.establishment_raison_sociale as string}} - - - scope - - {errors.scope && touched.scope && {errors.scope as string}} - - - type - - {errors.type && touched.type && {errors.type as string}} - - - - Administrateur - - {errors.beAdmin && touched.beAdmin && {errors.beAdmin as string}} - - - {user ? ( - - - - ) : ( - - )} - -
- - ) -} - -export default UserForm diff --git a/ui/components/espace_pro/Admin/utilisateurs/UserView.tsx b/ui/components/espace_pro/Admin/utilisateurs/UserView.tsx deleted file mode 100644 index 9ad484712a..0000000000 --- a/ui/components/espace_pro/Admin/utilisateurs/UserView.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import { Heading } from "@chakra-ui/react" -import { useRouter } from "next/router" -import { FC } from "react" -import { IUserRecruteurJson } from "shared" - -import { Breadcrumb } from "../../common/components/Breadcrumb" -import UserValidationHistory from "../../UserValidationHistory" - -import UserForm from "./UserForm" - -interface Props { - user: IUserRecruteurJson - refetchUser: any -} - -const UserView: FC = ({ user, refetchUser }) => { - const router = useRouter() - return ( - <> - - - Fiche utilisateur - - router.push("/espace-pro/admin/utilisateurs")} onUpdate={() => refetchUser()} /> - - - ) -} - -export default UserView diff --git a/ui/pages/espace-pro/admin/utilisateurs/[userId]/index.tsx b/ui/pages/espace-pro/admin/utilisateurs/[userId]/index.tsx index 795d1e1546..d0fbd77611 100644 --- a/ui/pages/espace-pro/admin/utilisateurs/[userId]/index.tsx +++ b/ui/pages/espace-pro/admin/utilisateurs/[userId]/index.tsx @@ -1,10 +1,9 @@ import { useRouter } from "next/router" import { useQuery } from "react-query" -import { IUserRecruteurJson } from "shared" import { getAuthServerSideProps } from "@/common/SSR/getAuthServerSideProps" import { Layout, LoadingEmptySpace } from "@/components/espace_pro" -import UserView from "@/components/espace_pro/Admin/utilisateurs/UserView" +import { AdminUserForm } from "@/components/espace_pro/Admin/utilisateurs/AdminUserForm" import { authProvider, withAuth } from "@/components/espace_pro/withAuth" import { apiGet } from "@/utils/api.utils" @@ -13,7 +12,7 @@ const AdminUserView = ({ userId }: { userId: string }) => { data: user, isLoading, refetch: refetchUser, - } = useQuery({ + } = useQuery({ queryKey: ["adminusersview"], queryFn: async () => { const user = await apiGet("/admin/users/:userId", { params: { userId } }) @@ -26,7 +25,7 @@ const AdminUserView = ({ userId }: { userId: string }) => { return } - return + return } function AdminUserViewPage() { diff --git a/ui/pages/espace-pro/admin/utilisateurs/index.tsx b/ui/pages/espace-pro/admin/utilisateurs/index.tsx index 426ca63ded..41843aa870 100644 --- a/ui/pages/espace-pro/admin/utilisateurs/index.tsx +++ b/ui/pages/espace-pro/admin/utilisateurs/index.tsx @@ -6,23 +6,17 @@ import AdminUserList from "@/components/espace_pro/Admin/utilisateurs/AdminUserL import { Breadcrumb } from "@/components/espace_pro/common/components/Breadcrumb" import { authProvider, withAuth } from "@/components/espace_pro/withAuth" -const AdminUsers = () => { - const title = "Gestion des administrateurs" - return ( - - - - {title} - - - - ) -} - function AdminUsersPage() { + const title = "Gestion des administrateurs" return ( - + + + + {title} + + + ) } diff --git a/ui/utils/api.ts b/ui/utils/api.ts index 17ccfc64c2..599c3c935a 100644 --- a/ui/utils/api.ts +++ b/ui/utils/api.ts @@ -1,9 +1,10 @@ import { captureException } from "@sentry/nextjs" import Axios from "axios" -import { IJobWritable, INewDelegations, IRoutes, IUserRecruteur, parseEnumOrError } from "shared" +import { IJobWritable, INewDelegations, IRoutes, parseEnumOrError } from "shared" import { BusinessErrorCodes } from "shared/constants/errorCodes" import { OPCOS } from "shared/constants/recruteur" import { AccessEntityType, AccessStatus } from "shared/models/roleManagement.model" +import { IUser2 } from "shared/models/user2.model" import { IEntrepriseInformations } from "shared/routes/recruiters.routes" import { publicConfig } from "../config.public" @@ -77,7 +78,7 @@ export const updateUserValidationHistory = ({ }) => apiPut("/user/:userId/organization/:organizationId/permission", { params: { userId, organizationId }, body: { organizationType, status, reason } }).catch(errorHandler) export const deleteCfa = async (userId) => await API.delete(`/user`, { params: { userId } }).catch(errorHandler) export const deleteEntreprise = (userId: string, recruiterId: string) => apiDelete(`/user`, { querystring: { userId, recruiterId } }).catch(errorHandler) -export const createUser = (userRecruteur: IUserRecruteur) => apiPost("/admin/users", { body: userRecruteur }) +export const createAdminUser = (user: IUser2) => apiPost("/admin/users", { body: user }) // Temporaire, en attendant d'ajuster le modèle pour n'avoir qu'une seul source de données pour les entreprises /** From 26d6986f6bdde664ce4a2119e586165d64f6c9c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9my=20Auricoste?= Date: Wed, 20 Mar 2024 16:42:16 +0100 Subject: [PATCH 47/78] fix: update of user --- server/src/http/controllers/user.controller.ts | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/server/src/http/controllers/user.controller.ts b/server/src/http/controllers/user.controller.ts index 562c0e20e2..5a130f4847 100644 --- a/server/src/http/controllers/user.controller.ts +++ b/server/src/http/controllers/user.controller.ts @@ -107,15 +107,16 @@ export default (server: Server) => { async (req, res) => { const { email, ...userPayload } = req.body const { userId } = req.params - const formattedEmail = email?.toLocaleLowerCase() - - const exist = await User2.findOne({ email: formattedEmail, _id: { $ne: userId } }).lean() + const newEmail = email?.toLocaleLowerCase() - if (exist) { - return res.status(400).send({ error: true, reason: "EMAIL_TAKEN" }) + if (newEmail) { + const exist = await User2.findOne({ email: newEmail, _id: { $ne: userId } }).lean() + if (exist) { + return res.status(400).send({ error: true, reason: "EMAIL_TAKEN" }) + } } - const update = { email: formattedEmail, ...userPayload } + const update = { ...userPayload, ...(newEmail ? { email: newEmail } : {}) } const updatedUser = await User2.findOneAndUpdate({ _id: userId }, update).lean() if (!updatedUser) { From 98d1262d733f3208a8c09e2ef5ae3999e520c9cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9my=20Auricoste?= Date: Wed, 20 Mar 2024 17:53:59 +0100 Subject: [PATCH 48/78] fix: migration entreprises et cfa + modification compte --- .../src/http/controllers/user.controller.ts | 19 ++++--- server/src/jobs/multiCompte/migrationUsers.ts | 22 ++++++--- server/src/security/authenticationService.ts | 2 +- server/src/security/authorisationService.ts | 5 +- server/src/services/roleManagement.service.ts | 2 +- server/src/services/userRecruteur.service.ts | 18 ++++--- ui/pages/espace-pro/compte.tsx | 49 +++++-------------- ui/utils/api.ts | 13 ++--- 8 files changed, 64 insertions(+), 66 deletions(-) diff --git a/server/src/http/controllers/user.controller.ts b/server/src/http/controllers/user.controller.ts index 5a130f4847..9fb324d69e 100644 --- a/server/src/http/controllers/user.controller.ts +++ b/server/src/http/controllers/user.controller.ts @@ -1,12 +1,14 @@ import Boom from "boom" import { CFA, VALIDATION_UTILISATEUR } from "shared/constants/recruteur" import { IJob, IRecruiter, getUserStatus, zRoutes } from "shared/index" +import { ICFA } from "shared/models/cfa.model" +import { IEntreprise } from "shared/models/entreprise.model" import { AccessEntityType, AccessStatus } from "shared/models/roleManagement.model" import { getLastStatusEvent } from "shared/utils/getLastStatusEvent" import { stopSession } from "@/common/utils/session.service" import { getUserFromRequest } from "@/security/authenticationService" -import { modifyPermissionToUser } from "@/services/roleManagement.service" +import { modifyPermissionToUser, roleToUserType } from "@/services/roleManagement.service" import { Cfa, Entreprise, RoleManagement, User2 } from "../../common/model/index" import { getStaticFilePath } from "../../common/utils/getStaticFilePath" @@ -160,7 +162,6 @@ export default (server: Server) => { user_id: userId, // TODO à activer lorsque le frontend passe organizationId correctement // authorized_id: organizationId, - authorized_type: { $in: [AccessEntityType.ENTREPRISE, AccessEntityType.CFA] }, }).lean() if (!role) { throw Boom.badRequest("role not found") @@ -169,10 +170,16 @@ export default (server: Server) => { if (!user) { throw Boom.badRequest("user not found") } - const type = role.authorized_type === AccessEntityType.CFA ? CFA : ENTREPRISE - const organization = await (type === CFA ? Cfa : Entreprise).findOne({ _id: role.authorized_id }).lean() - if (!organization) { - throw Boom.internal(`inattendu : impossible de trouver l'organization avec id=${role.authorized_id}`) + const type = roleToUserType(role) + if (!type) { + throw Boom.internal("user type not found") + } + let organization: ICFA | IEntreprise | null = null + if (type === CFA || type === ENTREPRISE) { + organization = await (type === CFA ? Cfa : Entreprise).findOne({ _id: role.authorized_id }).lean() + if (!organization) { + throw Boom.internal(`inattendu : impossible de trouver l'organization avec id=${role.authorized_id}`) + } } let jobs: IJob[] = [] diff --git a/server/src/jobs/multiCompte/migrationUsers.ts b/server/src/jobs/multiCompte/migrationUsers.ts index 45e9c9c972..d01dcb7ccc 100644 --- a/server/src/jobs/multiCompte/migrationUsers.ts +++ b/server/src/jobs/multiCompte/migrationUsers.ts @@ -165,7 +165,7 @@ const migrationUserRecruteurs = async () => { if (!establishment_siret) { throw new Error("inattendu pour une ENTERPRISE: pas de establishment_siret") } - const entreprise: IEntreprise = { + const newEntreprise: IEntreprise = { _id: userRecruteur._id, origin, siret: establishment_siret, @@ -180,12 +180,15 @@ const migrationUserRecruteurs = async () => { updatedAt, status: userRecruteurStatusToEntrepriseStatus(oldStatus), } - const createdEntreprise = await Entreprise.create(entreprise) - stats.entrepriseCreated++ + let entreprise = await Entreprise.findOne({ siret: newEntreprise.siret }).lean() + if (!entreprise) { + entreprise = await Entreprise.create(newEntreprise) + stats.entrepriseCreated++ + } const roleManagement: Omit = { user_id: userRecruteur._id, authorized_type: AccessEntityType.ENTREPRISE, - authorized_id: createdEntreprise._id, + authorized_id: entreprise._id.toString(), createdAt: userRecruteur.createdAt, updatedAt: userRecruteur.updatedAt, origin, @@ -196,7 +199,7 @@ const migrationUserRecruteurs = async () => { if (!establishment_siret) { throw new Error("inattendu pour un CFA: pas de establishment_siret") } - const cfa: ICFA = { + const newCfa: ICFA = { _id: userRecruteur._id, siret: establishment_siret, address, @@ -208,12 +211,15 @@ const migrationUserRecruteurs = async () => { createdAt, updatedAt, } - const createdCfa = await Cfa.create(cfa) - stats.cfaCreated++ + let cfa = await Cfa.findOne({ siret: newCfa.siret }).lean() + if (!cfa) { + cfa = await Cfa.create(newCfa) + stats.cfaCreated++ + } const roleManagement: Omit = { user_id: userRecruteur._id, authorized_type: AccessEntityType.CFA, - authorized_id: createdCfa._id, + authorized_id: cfa._id.toString(), createdAt: userRecruteur.createdAt, updatedAt: userRecruteur.updatedAt, origin, diff --git a/server/src/security/authenticationService.ts b/server/src/security/authenticationService.ts index a7298f8a0a..8f095cbdb8 100644 --- a/server/src/security/authenticationService.ts +++ b/server/src/security/authenticationService.ts @@ -69,7 +69,7 @@ async function authCookieSession(req: FastifyRequest): Promise { +export const roleToUserType = (role: IRoleManagement) => { switch (role.authorized_type) { case AccessEntityType.ADMIN: return ADMIN diff --git a/server/src/services/userRecruteur.service.ts b/server/src/services/userRecruteur.service.ts index 34832a420f..9032343422 100644 --- a/server/src/services/userRecruteur.service.ts +++ b/server/src/services/userRecruteur.service.ts @@ -94,9 +94,14 @@ export const getUserRecruteurByRecruiter = async (recruiter: IRecruiter): Promis } } -export const userAndRoleAndOrganizationToUserRecruteur = (user: IUser2, role: IRoleManagement, organisme: ICFA | IEntreprise, formulaire: IRecruiter | null): IUserRecruteur => { +export const userAndRoleAndOrganizationToUserRecruteur = ( + user: IUser2, + role: IRoleManagement, + organisme: ICFA | IEntreprise | null, + formulaire: IRecruiter | null +): IUserRecruteur => { const { email, first_name, last_name, phone, last_action_date, _id } = user - const organismeType = "status" in organisme ? ENTREPRISE : CFA + const organismeType = organisme ? ("status" in organisme ? ENTREPRISE : CFA) : null const oldStatus: IUserStatusValidation[] = [ ...role.status.map(({ date, reason, status, validation_type, granted_by }) => { const userRecruteurStatus = roleStatusToUserRecruteurStatus(status) @@ -109,7 +114,7 @@ export const userAndRoleAndOrganizationToUserRecruteur = (user: IUser2, role: IR } }), ] - if ("status" in organisme) { + if (organisme && "status" in organisme) { organisme.status .flatMap((event) => (event.status === EntrepriseStatus.ERROR ? [entrepriseStatusEventToUserRecruteurStatusEvent(event, ETAT_UTILISATEUR.ERROR)] : [])) .forEach((event) => oldStatus.push(event)) @@ -118,9 +123,9 @@ export const userAndRoleAndOrganizationToUserRecruteur = (user: IUser2, role: IR const roleType = role.authorized_type === AccessEntityType.OPCO ? OPCO : role.authorized_type === AccessEntityType.ADMIN ? ADMIN : null const type = roleType ?? organismeType ?? null if (!type) throw Boom.internal("unexpected: no type found") - const { siret, address, address_detail, geo_coordinates, origin, raison_sociale, enseigne } = organisme + const { siret, address, address_detail, geo_coordinates, origin, raison_sociale, enseigne } = organisme ?? {} let entrepriseFields = {} - if ("idcc" in organisme) { + if (organisme && "idcc" in organisme) { const { idcc, opco } = organisme entrepriseFields = { idcc, opco } if (formulaire) { @@ -278,7 +283,8 @@ export const createUser = async ( } export const updateUser2Fields = (userId: ObjectIdType, fields: Partial) => { - return User2.findOneAndUpdate({ _id: userId }, fields, { new: true }) + const { email, ...otherFields } = fields + return User2.findOneAndUpdate({ _id: userId }, { ...otherFields, ...(email ? { email: email.toLocaleLowerCase() } : {}) }, { new: true }) } export const validateUserEmail = async (userId: ObjectIdType) => { diff --git a/ui/pages/espace-pro/compte.tsx b/ui/pages/espace-pro/compte.tsx index fc4096f73a..f80d2861d2 100644 --- a/ui/pages/espace-pro/compte.tsx +++ b/ui/pages/espace-pro/compte.tsx @@ -17,7 +17,7 @@ import Layout from "../../components/espace_pro/Layout" import ModificationCompteEmail from "../../components/espace_pro/ModificationCompteEmail" import { authProvider, withAuth } from "../../components/espace_pro/withAuth" import { ArrowDropRightLine, ArrowRightLine } from "../../theme/components/icons" -import { getUser, updateEntreprise } from "../../utils/api" +import { getUser } from "../../utils/api" function Compte() { const client = useQueryClient() @@ -43,36 +43,16 @@ function Compte() { } const { data, isLoading } = useQuery("user", () => getUser(user._id.toString())) - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const userMutation = useMutation(({ userId, establishment_id, values, email, setFieldError }: any) => updateEntreprise(userId, establishment_id, values), { - onSuccess: (_, variables) => { - client.invalidateQueries("user") - toast({ - title: "Mise à jour enregistrée avec succès", - position: "top-right", - status: "success", - duration: 2000, - isClosable: true, - }) - - if (variables.email !== variables.values.email) { - ModificationEmailPopup.onOpen() - } - }, - onError: (_, variables) => { - variables.setFieldError("email", "L'adresse mail est déjà associée à un compte La bonne alternance.") - }, - }) - - const partenaireMutation = useMutation( - // eslint-disable-next-line @typescript-eslint/no-unused-vars - ({ userId, values, email, setFieldError }: any) => - apiPut("/etablissement/:id", { + const userMutation = useMutation( + ({ values }: { values: any; isChangingEmail: boolean; setFieldError: any }) => { + const userId = user._id.toString() + return apiPut("/etablissement/:id", { params: { id: userId, }, - body: { ...values, _id: user._id.toString() }, - }), + body: { ...values, _id: userId }, + }) + }, { onSuccess: (_, variables) => { client.invalidateQueries("user") @@ -84,12 +64,12 @@ function Compte() { isClosable: true, }) - if (variables.email !== variables.values.email) { + if (variables.isChangingEmail) { ModificationEmailPopup.onOpen() } }, - onError: (error: any, variables) => { - variables.setFieldError("email", error.message) + onError: (_, variables) => { + variables.setFieldError("email", "L'adresse mail est déjà associée à un compte La bonne alternance.") }, } ) @@ -137,11 +117,8 @@ function Compte() { })} onSubmit={async (values, { setSubmitting, setFieldError }) => { setSubmitting(true) - if (user.type === AUTHTYPE.ENTREPRISE) { - userMutation.mutate({ userId: data._id, establishment_id: user.establishment_id, values, email: data.email, setFieldError }) - } else { - partenaireMutation.mutate({ userId: data._id, values, email: data.email, setFieldError }) - } + const isChangingEmail = data.email !== values.email + userMutation.mutate({ values, isChangingEmail, setFieldError }) setSubmitting(false) }} > diff --git a/ui/utils/api.ts b/ui/utils/api.ts index 599c3c935a..4644a5e783 100644 --- a/ui/utils/api.ts +++ b/ui/utils/api.ts @@ -84,12 +84,13 @@ export const createAdminUser = (user: IUser2) => apiPost("/admin/users", { body: /** * KBA 20230511 : (migration db) : casting des valueurs coté collection recruiter, car les champs ne sont plus identiques avec la collection userRecruteur. */ -export const updateEntreprise = async (userId: string, establishment_id: string, user: any) => - await Promise.all([ - updateUser(userId, user), - // - updateFormulaire(establishment_id, user), - ]) +export const updateEntreprise = async (userId: string, establishment_id: string | undefined, user: any) => { + const promises: Promise[] = [updateUser(userId, user)] + if (establishment_id) { + promises.push(updateFormulaire(establishment_id, user)) + } + await Promise.all(promises) +} export const updateEntrepriseAdmin = async (userId: string, establishment_id: string, user: any) => await Promise.all([ From d14cface319baa9ca5707410dd4eacd5a492d779 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9my=20Auricoste?= Date: Thu, 21 Mar 2024 14:09:51 +0100 Subject: [PATCH 49/78] fix: refactor ids + clean unused function --- .../src/http/controllers/user.controller.ts | 16 +++-------- server/src/jobs/multiCompte/migrationUsers.ts | 14 ++++++---- server/src/services/userRecruteur.service.ts | 28 ++++--------------- 3 files changed, 18 insertions(+), 40 deletions(-) diff --git a/server/src/http/controllers/user.controller.ts b/server/src/http/controllers/user.controller.ts index 9fb324d69e..dd15e3a51e 100644 --- a/server/src/http/controllers/user.controller.ts +++ b/server/src/http/controllers/user.controller.ts @@ -107,7 +107,7 @@ export default (server: Server) => { onRequest: [server.auth(zRoutes.put["/admin/users/:userId"])], }, async (req, res) => { - const { email, ...userPayload } = req.body + const { email } = req.body const { userId } = req.params const newEmail = email?.toLocaleLowerCase() @@ -118,9 +118,7 @@ export default (server: Server) => { } } - const update = { ...userPayload, ...(newEmail ? { email: newEmail } : {}) } - - const updatedUser = await User2.findOneAndUpdate({ _id: userId }, update).lean() + const updatedUser = await updateUser2Fields(userId, req.body) if (!updatedUser) { throw Boom.internal(`could not update one user from query=${JSON.stringify({ _id: userId })}`) } @@ -251,20 +249,14 @@ export default (server: Server) => { onRequest: [server.auth(zRoutes.put["/user/:userId"])], }, async (req, res) => { - const { email, ...userPayload } = req.body + const { email } = req.body const { userId } = req.params - const formattedEmail = email?.toLocaleLowerCase() - const exist = await User2.findOne({ email: formattedEmail, _id: { $ne: userId } }).lean() - if (exist) { return res.status(400).send({ error: true, reason: "EMAIL_TAKEN" }) } - - const update = { email: formattedEmail, ...userPayload } - - await updateUser2Fields(userId, update) + await updateUser2Fields(userId, req.body) const user = await getUserRecruteurById(userId) return res.status(200).send(user) } diff --git a/server/src/jobs/multiCompte/migrationUsers.ts b/server/src/jobs/multiCompte/migrationUsers.ts index d01dcb7ccc..ee56d2e6f9 100644 --- a/server/src/jobs/multiCompte/migrationUsers.ts +++ b/server/src/jobs/multiCompte/migrationUsers.ts @@ -9,6 +9,8 @@ import { AccessEntityType, AccessStatus, IRoleManagement, IRoleManagementEvent } import { IUser2, IUserStatusEvent, UserEventType } from "shared/models/user2.model.js" import { IUserRecruteur } from "shared/models/usersRecruteur.model.js" +import { ObjectId } from "@/common/mongodb.js" + import { logger } from "../../common/logger.js" import { Appointment, Recruiter, User, UserRecruteur } from "../../common/model/index.js" import { Cfa } from "../../common/model/schema/multiCompte/cfa.schema.js" @@ -166,7 +168,7 @@ const migrationUserRecruteurs = async () => { throw new Error("inattendu pour une ENTERPRISE: pas de establishment_siret") } const newEntreprise: IEntreprise = { - _id: userRecruteur._id, + _id: new ObjectId(), origin, siret: establishment_siret, address, @@ -186,7 +188,7 @@ const migrationUserRecruteurs = async () => { stats.entrepriseCreated++ } const roleManagement: Omit = { - user_id: userRecruteur._id, + user_id: newUser._id, authorized_type: AccessEntityType.ENTREPRISE, authorized_id: entreprise._id.toString(), createdAt: userRecruteur.createdAt, @@ -200,7 +202,7 @@ const migrationUserRecruteurs = async () => { throw new Error("inattendu pour un CFA: pas de establishment_siret") } const newCfa: ICFA = { - _id: userRecruteur._id, + _id: new ObjectId(), siret: establishment_siret, address, address_detail, @@ -217,7 +219,7 @@ const migrationUserRecruteurs = async () => { stats.cfaCreated++ } const roleManagement: Omit = { - user_id: userRecruteur._id, + user_id: newUser._id, authorized_type: AccessEntityType.CFA, authorized_id: cfa._id.toString(), createdAt: userRecruteur.createdAt, @@ -228,7 +230,7 @@ const migrationUserRecruteurs = async () => { await RoleManagement.create(roleManagement) } else if (type === "ADMIN") { const roleManagement: Omit = { - user_id: userRecruteur._id, + user_id: newUser._id, authorized_type: AccessEntityType.ADMIN, authorized_id: "", createdAt: userRecruteur.createdAt, @@ -241,7 +243,7 @@ const migrationUserRecruteurs = async () => { } else if (type === "OPCO") { const opco = parseEnumOrError(OPCOS, scope ?? null) const roleManagement: Omit = { - user_id: userRecruteur._id, + user_id: newUser._id, authorized_type: AccessEntityType.OPCO, authorized_id: opco, createdAt: userRecruteur.createdAt, diff --git a/server/src/services/userRecruteur.service.ts b/server/src/services/userRecruteur.service.ts index 9032343422..6f2925e09a 100644 --- a/server/src/services/userRecruteur.service.ts +++ b/server/src/services/userRecruteur.service.ts @@ -45,12 +45,16 @@ const getOrganismeFromRole = async (role: IRoleManagement): Promise getUserRecruteurByUser2Query({ _id: typeof id === "string" ? new ObjectId(id) : id }) export const getUserRecruteurByEmail = (email: string) => getUserRecruteurByUser2Query({ email }) -export const getUserRecruteurByRecruiter = async (recruiter: IRecruiter): Promise => { - const { cfa_delegated_siret, establishment_siret } = recruiter - if (cfa_delegated_siret) { - const cfa = await Cfa.findOne({ siret: cfa_delegated_siret }).lean() - if (!cfa) { - throw new Error(`cfa with cfa_delegated_siret=${cfa_delegated_siret} not found`) - } - const role = await RoleManagement.findOne({ authorized_type: AccessEntityType.CFA, authorized_id: cfa._id.toString() }).lean() - if (!role) { - throw new Error(`role with authorized_id=${cfa._id} not found`) - } - return getUserRecruteurById(role.user_id) - } else { - const entreprise = await Entreprise.findOne({ siret: establishment_siret }).lean() - if (!entreprise) { - throw new Error(`entreprise with establishment_siret=${establishment_siret} not found`) - } - return getUserRecruteurById(entreprise._id) - } -} export const userAndRoleAndOrganizationToUserRecruteur = ( user: IUser2, From e8a7039fe6fd2b1941320100c79ad3280e0a592c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9my=20Auricoste?= Date: Thu, 21 Mar 2024 14:18:33 +0100 Subject: [PATCH 50/78] fix: fill establishment_id --- server/src/services/userRecruteur.service.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/server/src/services/userRecruteur.service.ts b/server/src/services/userRecruteur.service.ts index 6f2925e09a..376d18d7c9 100644 --- a/server/src/services/userRecruteur.service.ts +++ b/server/src/services/userRecruteur.service.ts @@ -109,13 +109,13 @@ export const userAndRoleAndOrganizationToUserRecruteur = ( if (!type) throw Boom.internal("unexpected: no type found") const { siret, address, address_detail, geo_coordinates, origin, raison_sociale, enseigne } = organisme ?? {} let entrepriseFields = {} - if (organisme && "idcc" in organisme) { + if (organisme && "opco" in organisme) { const { idcc, opco } = organisme entrepriseFields = { idcc, opco } - if (formulaire) { - const { establishment_id } = formulaire - Object.assign(entrepriseFields, { establishment_id }) - } + } + if (formulaire) { + const { establishment_id } = formulaire + Object.assign(entrepriseFields, { establishment_id }) } const userRecruteur: IUserRecruteur = { ...entrepriseFields, From 904d9984b1c11772a63d59a00f3a21b88efd454b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9my=20Auricoste?= Date: Thu, 21 Mar 2024 14:22:00 +0100 Subject: [PATCH 51/78] fix: ajout TODO --- .../administration/entreprise/[establishment_id]/edition.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/ui/pages/espace-pro/administration/entreprise/[establishment_id]/edition.tsx b/ui/pages/espace-pro/administration/entreprise/[establishment_id]/edition.tsx index 2c65987776..6b375ecf62 100644 --- a/ui/pages/espace-pro/administration/entreprise/[establishment_id]/edition.tsx +++ b/ui/pages/espace-pro/administration/entreprise/[establishment_id]/edition.tsx @@ -160,6 +160,7 @@ function EditionEntrepriseContact() { const { user } = useAuth() const { establishment_id } = router.query as { establishment_id: string } + // TODO pourquoi afficher le formulaire ? const { data, isLoading } = useQuery("formulaire-edition", () => getFormulaire(establishment_id), { cacheTime: 0, enabled: !!establishment_id }) if (isLoading || !establishment_id) { From 38aac7171126df6033514caa4d3478c2222e4223 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9my=20Auricoste?= Date: Thu, 21 Mar 2024 15:24:25 +0100 Subject: [PATCH 52/78] fix: creation offre par opco ou admin --- server/src/jobs/multiCompte/migrationUsers.ts | 1 + server/src/security/authorisationService.ts | 28 +++++------------ server/src/services/etablissement.service.ts | 9 ++++-- server/src/services/formulaire.service.ts | 30 ++++++++++--------- server/src/services/roleManagement.service.ts | 26 ++++++++++++++-- 5 files changed, 56 insertions(+), 38 deletions(-) diff --git a/server/src/jobs/multiCompte/migrationUsers.ts b/server/src/jobs/multiCompte/migrationUsers.ts index ee56d2e6f9..4607b8eaca 100644 --- a/server/src/jobs/multiCompte/migrationUsers.ts +++ b/server/src/jobs/multiCompte/migrationUsers.ts @@ -63,6 +63,7 @@ const migrationRecruiters = async () => { { "jobs._id": job._id }, { $set: { + // les ids des users sont identiques aux userRecruteurs. Les userRecruteurs sont migrés après pour écraser les infos de contact "jobs.$.managed_by": userRecruiter._id, }, }, diff --git a/server/src/security/authorisationService.ts b/server/src/security/authorisationService.ts index 370d88a657..5ad23258d8 100644 --- a/server/src/security/authorisationService.ts +++ b/server/src/security/authorisationService.ts @@ -4,7 +4,7 @@ import { CFA, ENTREPRISE, OPCOS } from "shared/constants/recruteur" import { IApplication, IJob, IRecruiter } from "shared/models" import { ICFA } from "shared/models/cfa.model" import { IEntreprise } from "shared/models/entreprise.model" -import { AccessEntityType, AccessStatus, IRoleManagement } from "shared/models/roleManagement.model" +import { AccessEntityType, IRoleManagement } from "shared/models/roleManagement.model" import { UserEventType } from "shared/models/user2.model" import { IRouteSchema, WithSecurityScheme } from "shared/routes/common.routes" import { AccessPermission, AccessResourcePath } from "shared/security/permissions" @@ -12,7 +12,8 @@ import { assertUnreachable, parseEnum } from "shared/utils" import { getLastStatusEvent } from "shared/utils/getLastStatusEvent" import { Primitive } from "type-fest" -import { Application, Cfa, Entreprise, Recruiter, RoleManagement, User2 } from "@/common/model" +import { Application, Cfa, Entreprise, Recruiter, User2 } from "@/common/model" +import { getComputedUserAccess, getGrantedRoles } from "@/services/roleManagement.service" import { isUserEmailChecked } from "@/services/userRecruteur.service" import { getUserFromRequest } from "./authenticationService" @@ -28,7 +29,8 @@ type Resources = { applications: Array } -type ComputedUserAccess = { +export type ComputedUserAccess = { + admin: boolean users: string[] entreprises: string[] cfas: string[] @@ -280,7 +282,7 @@ export async function authorizationMiddleware role.authorized_type === AccessEntityType.ADMIN) if (isAdmin) { return @@ -300,6 +302,7 @@ export async function authorizationMiddleware (role.authorized_type === AccessEntityType.CFA ? [role.authorized_id] : [])), - entreprises: grantedRoles.flatMap((role) => (role.authorized_type === AccessEntityType.ENTREPRISE ? [role.authorized_id] : [])), - opcos: grantedRoles.flatMap((role) => { - if (role.authorized_type === AccessEntityType.OPCO) { - const opco = parseEnum(OPCOS, role.authorized_id) - if (opco) { - return [opco] - } - } - return [] - }), - } + const userAccess: ComputedUserAccess = getComputedUserAccess(_id.toString(), grantedRoles) if (!isAuthorized(requestedAccess, userAccess, resources)) { throw Boom.forbidden("non autorisé") } diff --git a/server/src/services/etablissement.service.ts b/server/src/services/etablissement.service.ts index 6b0e4b6263..fe45073cfe 100644 --- a/server/src/services/etablissement.service.ts +++ b/server/src/services/etablissement.service.ts @@ -872,8 +872,13 @@ export const sendUserConfirmationEmail = async (user: IUser2) => { }) } -export const sendEmailConfirmationEntreprise = async (user: IUser2, recruteur: IRecruiter, accessStatus: AccessStatus, entrepriseStatus: EntrepriseStatus) => { - if (entrepriseStatus === EntrepriseStatus.ERROR || isUserEmailChecked(user) || accessStatus === AccessStatus.DENIED) { +export const sendEmailConfirmationEntreprise = async (user: IUser2, recruteur: IRecruiter, accessStatus: AccessStatus | null, entrepriseStatus: EntrepriseStatus | null) => { + if ( + entrepriseStatus !== EntrepriseStatus.VALIDE || + isUserEmailChecked(user) || + !accessStatus || + ![AccessStatus.GRANTED, AccessStatus.AWAITING_VALIDATION].includes(accessStatus) + ) { return } const isUserAwaiting = accessStatus === AccessStatus.AWAITING_VALIDATION diff --git a/server/src/services/formulaire.service.ts b/server/src/services/formulaire.service.ts index 548262f846..18dea33e6f 100644 --- a/server/src/services/formulaire.service.ts +++ b/server/src/services/formulaire.service.ts @@ -22,6 +22,7 @@ import { getCatalogueEtablissements, getCatalogueFormations } from "./catalogue. import dayjs from "./dayjs.service" import { sendEmailConfirmationEntreprise } from "./etablissement.service" import mailer, { sanitizeForEmail } from "./mailer.service" +import { getComputedUserAccess, getGrantedRoles } from "./roleManagement.service" import { getRomeDetailsFromDB } from "./rome.service" const { ObjectId } = pkg @@ -87,6 +88,11 @@ export const getFormulaires = async (query: FilterQuery, select: obj } } +const isAuthorizedToPublishJob = async ({ userId, entrepriseId }: { userId: ObjectIdType; entrepriseId: ObjectIdType }) => { + const access = getComputedUserAccess(userId.toString(), await getGrantedRoles(userId.toString())) + return access.admin || access.entreprises.includes(entrepriseId.toString()) +} + /** * @description Create job offer for formulaire */ @@ -101,21 +107,15 @@ export const createJob = async ({ job, id, user }: { job: IJobWritable; id: stri if (!organization) { throw Boom.internal(`inattendu : impossible retrouver l'organisation pour establishment_id=${id}`) } - const role = await RoleManagement.findOne({ - user_id: userId, - authorized_type: cfa_delegated_siret ? AccessEntityType.CFA : AccessEntityType.ENTREPRISE, - authorized_id: organization._id.toString(), - }).lean() - if (!role) { - throw Boom.internal(`inattendu : impossible retrouver le role pour establishment_id=${id}`) - } - const roleStatus = getLastStatusEvent(role.status)?.status - if (!roleStatus) { - throw Boom.internal(`inattendu : pas de status pour le role pour establishment_id=${id}`) + let isOrganizationValid = false + let entrepriseStatus: EntrepriseStatus | null = null + if (cfa_delegated_siret) { + isOrganizationValid = true + } else if ("status" in organization) { + entrepriseStatus = getLastStatusEvent((organization as IEntreprise).status)?.status ?? null + isOrganizationValid = entrepriseStatus === EntrepriseStatus.VALIDE && (await isAuthorizedToPublishJob({ userId, entrepriseId: organization._id })) } - const entreprise: IEntreprise | null = cfa_delegated_siret ? null : (organization as IEntreprise) - const entrepriseStatus = getLastStatusEvent(entreprise?.status)?.status - const isJobActive = roleStatus === AccessStatus.GRANTED && cfa_delegated_siret ? true : entrepriseStatus === EntrepriseStatus.VALIDE + const isJobActive = isOrganizationValid const newJobStatus = isJobActive ? JOB_STATUS.ACTIVE : JOB_STATUS.EN_ATTENTE // get user activation state if not managed by a CFA @@ -150,6 +150,8 @@ export const createJob = async ({ job, id, user }: { job: IJobWritable; id: stri if (!entrepriseStatus) { throw Boom.internal(`inattendu : pas de status pour l'entreprise pour establishment_id=${id}`) } + const role = await RoleManagement.findOne({ userId, authorized_type: AccessEntityType.ENTREPRISE, authorized_id: organization._id.toString() }).lean() + const roleStatus = getLastStatusEvent(role?.status)?.status ?? null await sendEmailConfirmationEntreprise(user, updatedFormulaire, roleStatus, entrepriseStatus) return updatedFormulaire } diff --git a/server/src/services/roleManagement.service.ts b/server/src/services/roleManagement.service.ts index 7b5466af85..272f989578 100644 --- a/server/src/services/roleManagement.service.ts +++ b/server/src/services/roleManagement.service.ts @@ -3,10 +3,11 @@ import type { ObjectId } from "mongodb" import { ETAT_UTILISATEUR, OPCOS } from "shared/constants/recruteur" import { IUserRecruteurPublic } from "shared/models" import { AccessEntityType, AccessStatus, IRoleManagement, IRoleManagementEvent } from "shared/models/roleManagement.model" -import { parseEnumOrError } from "shared/utils" +import { parseEnum, parseEnumOrError } from "shared/utils" import { getLastStatusEvent } from "shared/utils/getLastStatusEvent" import { Cfa, Entreprise, RoleManagement, User2 } from "@/common/model" +import { ComputedUserAccess } from "@/security/authorisationService" import { ADMIN, CFA, ENTREPRISE, OPCO } from "./constant.service" import { getFormulaireFromUserIdOrError } from "./formulaire.service" @@ -41,7 +42,7 @@ export const modifyPermissionToUser = async ( } } -const getGrantedRoles = async (userId: string) => { +export const getGrantedRoles = async (userId: string) => { return RoleManagement.find({ user_id: userId, $expr: { $eq: [{ $arrayElemAt: ["$status.status", -1] }, AccessStatus.GRANTED] } }).lean() } @@ -133,3 +134,24 @@ export const getPublicUserRecruteurPropsOrError = async ( } return commonFields } + +export const getComputedUserAccess = (userId: string, grantedRoles: IRoleManagement[]) => { + // TODO + // const indirectUserRoles = await RoleManagement.find({ }) + const userAccess: ComputedUserAccess = { + admin: grantedRoles.some((role) => role.authorized_type === AccessEntityType.ADMIN), + users: [userId], + cfas: grantedRoles.flatMap((role) => (role.authorized_type === AccessEntityType.CFA ? [role.authorized_id] : [])), + entreprises: grantedRoles.flatMap((role) => (role.authorized_type === AccessEntityType.ENTREPRISE ? [role.authorized_id] : [])), + opcos: grantedRoles.flatMap((role) => { + if (role.authorized_type === AccessEntityType.OPCO) { + const opco = parseEnum(OPCOS, role.authorized_id) + if (opco) { + return [opco] + } + } + return [] + }), + } + return userAccess +} From 4214883f39e91dcf173706da5017a3cb488d58b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9my=20Auricoste?= Date: Thu, 21 Mar 2024 16:42:51 +0100 Subject: [PATCH 53/78] fix: expose user access --- .../etablissementRecruteur.controller.ts | 12 +++---- .../src/http/controllers/login.controller.ts | 19 ++++++++++- server/src/security/authorisationService.ts | 10 +----- server/src/services/roleManagement.service.ts | 3 +- shared/models/computedUserAccess.model.ts | 16 ++++++++++ shared/models/index.ts | 1 + shared/routes/login.routes.ts | 14 +++++++- shared/routes/recruiters.routes.ts | 6 ++-- ui/common/SSR/getAuthServerSideProps.ts | 13 ++++++-- ui/components/espace_pro/withAuth.tsx | 3 +- ui/context/UserContext.tsx | 32 ++++++++++++++----- ui/pages/espace-pro/administration/index.tsx | 6 ++-- ui/utils/api.ts | 2 +- 13 files changed, 100 insertions(+), 37 deletions(-) create mode 100644 shared/models/computedUserAccess.model.ts diff --git a/server/src/http/controllers/etablissementRecruteur.controller.ts b/server/src/http/controllers/etablissementRecruteur.controller.ts index 1c81efd55e..33f0cdcf50 100644 --- a/server/src/http/controllers/etablissementRecruteur.controller.ts +++ b/server/src/http/controllers/etablissementRecruteur.controller.ts @@ -129,16 +129,16 @@ export default (server: Server) => { * Retourne les entreprises gérées par un CFA */ server.get( - "/etablissement/cfa/:userRecruteurId/entreprises", + "/etablissement/cfa/:cfaId/entreprises", { - schema: zRoutes.get["/etablissement/cfa/:userRecruteurId/entreprises"], - onRequest: [server.auth(zRoutes.get["/etablissement/cfa/:userRecruteurId/entreprises"])], + schema: zRoutes.get["/etablissement/cfa/:cfaId/entreprises"], + onRequest: [server.auth(zRoutes.get["/etablissement/cfa/:cfaId/entreprises"])], }, async (req, res) => { - const { userRecruteurId } = req.params - const cfa = await Cfa.findOne({ _id: userRecruteurId }).lean() + const { cfaId } = req.params + const cfa = await Cfa.findOne({ _id: cfaId }).lean() if (!cfa) { - throw Boom.notFound(`Aucun CFA ayant pour id ${userRecruteurId.toString()}`) + throw Boom.notFound(`Aucun CFA ayant pour id ${cfaId.toString()}`) } const cfa_delegated_siret = cfa.siret if (!cfa_delegated_siret) { diff --git a/server/src/http/controllers/login.controller.ts b/server/src/http/controllers/login.controller.ts index 72927366b7..85535caab1 100644 --- a/server/src/http/controllers/login.controller.ts +++ b/server/src/http/controllers/login.controller.ts @@ -7,7 +7,7 @@ import { getStaticFilePath } from "@/common/utils/getStaticFilePath" import { user2ToUserForToken } from "@/security/accessTokenService" import { getUserFromRequest } from "@/security/authenticationService" import { createAuthMagicLink } from "@/services/appLinks.service" -import { getPublicUserRecruteurPropsOrError } from "@/services/roleManagement.service" +import { getComputedUserAccess, getGrantedRoles, getPublicUserRecruteurPropsOrError } from "@/services/roleManagement.service" import { getUser2ByEmail } from "@/services/user2.service" import { startSession, stopSession } from "../../common/utils/session.service" @@ -135,6 +135,23 @@ export default (server: Server) => { } ) + server.get( + "/auth/access", + { + schema: zRoutes.get["/auth/access"], + onRequest: [server.auth(zRoutes.get["/auth/access"])], + }, + async (request, response) => { + if (!request.user) { + throw Boom.forbidden() + } + const userFromRequest = getUserFromRequest(request, zRoutes.get["/auth/access"]).value + const userId = userFromRequest._id.toString() + const userAccess = getComputedUserAccess(userId, await getGrantedRoles(userId)) + return response.status(200).send(userAccess) + } + ) + server.get( "/auth/logout", { diff --git a/server/src/security/authorisationService.ts b/server/src/security/authorisationService.ts index 5ad23258d8..2efd2109be 100644 --- a/server/src/security/authorisationService.ts +++ b/server/src/security/authorisationService.ts @@ -1,7 +1,7 @@ import Boom from "boom" import { FastifyRequest } from "fastify" import { CFA, ENTREPRISE, OPCOS } from "shared/constants/recruteur" -import { IApplication, IJob, IRecruiter } from "shared/models" +import { ComputedUserAccess, IApplication, IJob, IRecruiter } from "shared/models" import { ICFA } from "shared/models/cfa.model" import { IEntreprise } from "shared/models/entreprise.model" import { AccessEntityType, IRoleManagement } from "shared/models/roleManagement.model" @@ -29,14 +29,6 @@ type Resources = { applications: Array } -export type ComputedUserAccess = { - admin: boolean - users: string[] - entreprises: string[] - cfas: string[] - opcos: OPCOS[] -} - // Specify what we need to simplify mocking in tests type IRequest = Pick diff --git a/server/src/services/roleManagement.service.ts b/server/src/services/roleManagement.service.ts index 272f989578..7678fabf4b 100644 --- a/server/src/services/roleManagement.service.ts +++ b/server/src/services/roleManagement.service.ts @@ -1,13 +1,12 @@ import Boom from "boom" import type { ObjectId } from "mongodb" import { ETAT_UTILISATEUR, OPCOS } from "shared/constants/recruteur" -import { IUserRecruteurPublic } from "shared/models" +import { ComputedUserAccess, IUserRecruteurPublic } from "shared/models" import { AccessEntityType, AccessStatus, IRoleManagement, IRoleManagementEvent } from "shared/models/roleManagement.model" import { parseEnum, parseEnumOrError } from "shared/utils" import { getLastStatusEvent } from "shared/utils/getLastStatusEvent" import { Cfa, Entreprise, RoleManagement, User2 } from "@/common/model" -import { ComputedUserAccess } from "@/security/authorisationService" import { ADMIN, CFA, ENTREPRISE, OPCO } from "./constant.service" import { getFormulaireFromUserIdOrError } from "./formulaire.service" diff --git a/shared/models/computedUserAccess.model.ts b/shared/models/computedUserAccess.model.ts new file mode 100644 index 0000000000..7aac77da53 --- /dev/null +++ b/shared/models/computedUserAccess.model.ts @@ -0,0 +1,16 @@ +import { OPCOS } from "../constants/recruteur" +import { z } from "../helpers/zodWithOpenApi" + +import { enumToZod } from "./enumToZod" + +export const ZComputedUserAccess = z + .object({ + admin: z.boolean(), + users: z.array(z.string()), + entreprises: z.array(z.string()), + cfas: z.array(z.string()), + opcos: z.array(enumToZod(OPCOS)), + }) + .strict() + +export type ComputedUserAccess = z.output diff --git a/shared/models/index.ts b/shared/models/index.ts index d70fbed411..86861c236c 100644 --- a/shared/models/index.ts +++ b/shared/models/index.ts @@ -27,3 +27,4 @@ export * from "./unsubscribeOF.model" export * from "./unsubscribedLbaCompany.model" export * from "./user.model" export * from "./usersRecruteur.model" +export * from "./computedUserAccess.model" diff --git a/shared/routes/login.routes.ts b/shared/routes/login.routes.ts index 69ff26e0ee..7de2a32bee 100644 --- a/shared/routes/login.routes.ts +++ b/shared/routes/login.routes.ts @@ -1,5 +1,5 @@ import { z } from "../helpers/zodWithOpenApi" -import { ZUserRecruteurPublic } from "../models" +import { ZComputedUserAccess, ZUserRecruteurPublic } from "../models" import { zObjectId } from "../models/common" import { IRoutesDef } from "./common.routes" @@ -64,6 +64,18 @@ export const zLoginRoutes = { resources: {}, }, }, + "/auth/access": { + method: "get", + path: "/auth/access", + response: { + "200": ZComputedUserAccess, + }, + securityScheme: { + auth: "cookie-session", + access: null, + resources: {}, + }, + }, "/auth/logout": { method: "get", path: "/auth/logout", diff --git a/shared/routes/recruiters.routes.ts b/shared/routes/recruiters.routes.ts index d339812007..316fa92a70 100644 --- a/shared/routes/recruiters.routes.ts +++ b/shared/routes/recruiters.routes.ts @@ -105,10 +105,10 @@ export const zRecruiterRoutes = { }, securityScheme: null, }, - "/etablissement/cfa/:userRecruteurId/entreprises": { + "/etablissement/cfa/:cfaId/entreprises": { method: "get", - path: "/etablissement/cfa/:userRecruteurId/entreprises", - params: z.object({ userRecruteurId: zObjectId }).strict(), + path: "/etablissement/cfa/:cfaId/entreprises", + params: z.object({ cfaId: zObjectId }).strict(), response: { "200": z.array(ZRecruiter), }, diff --git a/ui/common/SSR/getAuthServerSideProps.ts b/ui/common/SSR/getAuthServerSideProps.ts index ebee978db0..f45e16624b 100644 --- a/ui/common/SSR/getAuthServerSideProps.ts +++ b/ui/common/SSR/getAuthServerSideProps.ts @@ -9,15 +9,22 @@ export const getAuthServerSideProps = async (context) => { return {} } try { - const session: IUserRecruteurPublic = await apiGet( + const user: IUserRecruteurPublic = await apiGet( `/auth/session`, {}, { headers: context.req.headers, } ) - return { auth: session } + const userAccess = await apiGet( + `/auth/access`, + {}, + { + headers: context.req.headers, + } + ) + return { user, userAccess } } catch (e) { - return { auth: null } + return { user: null, userAccess: null } } } diff --git a/ui/components/espace_pro/withAuth.tsx b/ui/components/espace_pro/withAuth.tsx index 682a73f5c0..156a23afbd 100644 --- a/ui/components/espace_pro/withAuth.tsx +++ b/ui/components/espace_pro/withAuth.tsx @@ -37,8 +37,9 @@ export const withAuth = (Component, scope = null) => { export const authProvider = (Component) => { const Wrapper = (props) => { + const { user, userAccess } = props return ( - + ) diff --git a/ui/context/UserContext.tsx b/ui/context/UserContext.tsx index d920de9ac1..abe244af8c 100644 --- a/ui/context/UserContext.tsx +++ b/ui/context/UserContext.tsx @@ -1,12 +1,13 @@ import { Spinner } from "@chakra-ui/react" -import React, { useState, useEffect, createContext, FC, PropsWithChildren, useContext } from "react" -import { IUserRecruteurPublic } from "shared" +import React, { createContext, useContext, useEffect, useState } from "react" +import { ComputedUserAccess, IUserRecruteurPublic } from "shared" import { emitter } from "@/common/utils/emitter" import { apiGet } from "@/utils/api.utils" interface IAuthContext { user?: IUserRecruteurPublic + userAccess?: ComputedUserAccess setUser: (user?: IUserRecruteurPublic) => void } @@ -15,10 +16,6 @@ export const AuthContext = createContext({ setUser: () => {}, }) -interface Props extends PropsWithChildren { - initialUser?: IUserRecruteurPublic -} - export async function getSession(): Promise { try { const session: IUserRecruteurPublic = await apiGet(`/auth/session`, {}) @@ -28,14 +25,32 @@ export async function getSession(): Promise { } } -export const UserContext: FC = ({ children, initialUser }) => { +export async function getUserAccess() { + try { + const userAccess: ComputedUserAccess = await apiGet(`/auth/access`, {}) + return userAccess + } catch (error) { + return null + } +} + +export const UserContext = ({ + children, + user: initialUser, + userAccess: initialUserAccess, +}: Pick & { + children: React.ReactNode +}) => { const [user, setUser] = useState(initialUser) + const [userAccess, setUserAccess] = useState(initialUserAccess) const [isLoading, setIsLoading] = useState(!initialUser) useEffect(() => { async function getUser() { const user = initialUser ?? (await getSession()) + const userAccess = await getUserAccess() setUser(user) + setUserAccess(userAccess) setIsLoading(false) } if (!initialUser) { @@ -49,6 +64,7 @@ export const UserContext: FC = ({ children, initialUser }) => { if (response.status === 401) { //Auto logout user when token is invalid setUser(null) + setUserAccess(null) } } emitter.on("http:error", handler) @@ -59,7 +75,7 @@ export const UserContext: FC = ({ children, initialUser }) => { return } - return {children} + return {children} } export const useAuth = () => useContext(AuthContext) diff --git a/ui/pages/espace-pro/administration/index.tsx b/ui/pages/espace-pro/administration/index.tsx index 300f9afc0b..38ec8f3a93 100644 --- a/ui/pages/espace-pro/administration/index.tsx +++ b/ui/pages/espace-pro/administration/index.tsx @@ -57,7 +57,7 @@ function ListeEntreprise() { const [currentEntreprise, setCurrentEntreprise] = useState() const confirmationSuppression = useDisclosure() const router = useRouter() - const { user } = useAuth() + const { userAccess } = useAuth() const toast = useToast() useEffect(() => { @@ -73,7 +73,9 @@ function ListeEntreprise() { } }, []) - const { data, isLoading } = useQuery("listeEntreprise", () => getEntreprisesManagedByCfa(user._id.toString())) + const cfaId = userAccess?.cfas.at(0) + + const { data, isLoading } = useQuery("listeEntreprise", () => getEntreprisesManagedByCfa(cfaId), { enabled: Boolean(cfaId) }) if (isLoading) { return diff --git a/ui/utils/api.ts b/ui/utils/api.ts index 4644a5e783..f0031eabb4 100644 --- a/ui/utils/api.ts +++ b/ui/utils/api.ts @@ -108,7 +108,7 @@ export const sendValidationLink = async (userId: string) => await apiPost("/logi /** * Etablissement API */ -export const getEntreprisesManagedByCfa = (userId: string) => apiGet("/etablissement/cfa/:userRecruteurId/entreprises", { params: { userRecruteurId: userId } }) +export const getEntreprisesManagedByCfa = (cfaId: string) => apiGet("/etablissement/cfa/:cfaId/entreprises", { params: { cfaId } }) export const getCfaInformation = async (siret: string) => apiGet("/etablissement/cfa/:siret", { params: { siret } }) export const getEntrepriseInformation = async ( From dae799d0cae5bb20c7281978c106d97818bf99f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9my=20Auricoste?= Date: Mon, 25 Mar 2024 12:00:46 +0100 Subject: [PATCH 54/78] fix: lbajob.service --- server/src/services/lbajob.service.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/server/src/services/lbajob.service.ts b/server/src/services/lbajob.service.ts index f4852eefd9..c2fb49f290 100644 --- a/server/src/services/lbajob.service.ts +++ b/server/src/services/lbajob.service.ts @@ -64,15 +64,15 @@ export const getJobs = async ({ distance, lat, lon, romes, niveau }: { distance: }, }) - const jobs: IRecruiter[] = await Recruiter.aggregate(stages) + const recruiters: IRecruiter[] = await Recruiter.aggregate(stages) const filteredJobs = await Promise.all( - jobs.map(async (job) => { + recruiters.map(async (job) => { const jobs: any[] = [] if (job.is_delegated && job.cfa_delegated_siret) { const cfa = await Cfa.findOne({ siret: job.cfa_delegated_siret }) - const cfaUser = await getUser2ManagingOffer(job) + const cfaUser = await getUser2ManagingOffer(jobs[0]) job.phone = cfaUser.phone job.email = cfaUser.email From f4e2e48822c98a6ab14687a0e8083dfbf24b24ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9my=20Auricoste?= Date: Mon, 25 Mar 2024 15:46:12 +0100 Subject: [PATCH 55/78] fix: update seed --- .infra/files/configs/mongodb/seed.gpg | 4 ++-- .talismanrc | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.infra/files/configs/mongodb/seed.gpg b/.infra/files/configs/mongodb/seed.gpg index f3e7b1de97..d9c1c24ac4 100644 --- a/.infra/files/configs/mongodb/seed.gpg +++ b/.infra/files/configs/mongodb/seed.gpg @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8b3b88b65cdfcf23777ac85fb4557947424a69ea64057f0f9476cdd0fe6cdbd7 -size 278818873 +oid sha256:72ca7960eba2339534e4b7fa7805a6dfbb48241d3fbaf104cd68392f5ef30f78 +size 281744734 diff --git a/.talismanrc b/.talismanrc index d46a03db60..65b231a1ed 100644 --- a/.talismanrc +++ b/.talismanrc @@ -22,7 +22,7 @@ fileignoreconfig: - filename: .infra/files/configs/mongodb/mongod.conf checksum: 718bee5f44edc101636be8f11173ede5b728f2858abc3c26466ff9435f0d11de - filename: .infra/files/configs/mongodb/seed.gpg - checksum: f3da269202d63aa1ad66b8eaa148076cf0135eec6b7fabe2394fcc3eabb466d2 + checksum: 9c81ab35ef9f955861274817f1a24e8416d64ddabec6db9329244980e92b8921 - filename: .infra/files/scripts/seed.sh checksum: ddafc86248e8fd5f7c24ca5a62be703083f7704395f17fb7b43bc8e44227d561 - filename: .infra/local/mongod.conf From 768712d0b67a5112696d229dd26b3e6ee3b6b53d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9my=20Auricoste?= Date: Tue, 26 Mar 2024 10:53:06 +0100 Subject: [PATCH 56/78] fix: suppression bouton modifier l entreprise --- ui/components/espace_pro/ListeOffres.tsx | 6 +++--- .../entreprise/[siret_userId]/[establishment_id]/index.tsx | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/ui/components/espace_pro/ListeOffres.tsx b/ui/components/espace_pro/ListeOffres.tsx index fe892b6dc3..b3663a0445 100644 --- a/ui/components/espace_pro/ListeOffres.tsx +++ b/ui/components/espace_pro/ListeOffres.tsx @@ -70,7 +70,7 @@ const NumberCell = ({ children }) => { ) } -export default function ListeOffres() { +export default function ListeOffres({ hideModify = false }: { hideModify?: boolean }) { const router = useRouter() const confirmationSuppression = useDisclosure() const [currentOffre, setCurrentOffre] = useState() @@ -120,7 +120,7 @@ export default function ListeOffres() { {entrepriseTitle} - {user.type !== AUTHTYPE.OPCO && ( + {!hideModify && user.type !== AUTHTYPE.OPCO && ( @@ -322,7 +322,7 @@ export default function ListeOffres() { {establishment_raison_sociale ?? `SIRET ${establishment_siret}`} - {user.type !== AUTHTYPE.OPCO && ( + {!hideModify && user.type !== AUTHTYPE.OPCO && ( diff --git a/ui/pages/espace-pro/administration/opco/entreprise/[siret_userId]/[establishment_id]/index.tsx b/ui/pages/espace-pro/administration/opco/entreprise/[siret_userId]/[establishment_id]/index.tsx index 0c36f4239d..339992c29c 100644 --- a/ui/pages/espace-pro/administration/opco/entreprise/[siret_userId]/[establishment_id]/index.tsx +++ b/ui/pages/espace-pro/administration/opco/entreprise/[siret_userId]/[establishment_id]/index.tsx @@ -7,7 +7,7 @@ import { authProvider, withAuth } from "../../../../../../../components/espace_p function OpcoEntrepriseListOffre() { return ( - + ) } From a227090aa7be9af63df18f338c09dd3c19cadf83 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9my=20Auricoste?= Date: Tue, 26 Mar 2024 17:17:30 +0100 Subject: [PATCH 57/78] fix: tests cypress + bugs --- ...-recruiter-account-manual-validation.cy.ts | 4 +-- cypress/pages/SearchForm.ts | 2 +- .../etablissementRecruteur.controller.ts | 4 +-- .../src/http/controllers/jobs.controller.ts | 6 +++++ .../user/misc/updateSiretInfosInError.ts | 4 +-- server/src/security/authorisationService.ts | 1 - server/src/services/etablissement.service.ts | 25 ++++++++++++------- server/src/services/formulaire.service.ts | 4 +-- server/src/services/roleManagement.service.ts | 13 +++++++--- server/src/services/userRecruteur.service.ts | 6 +++++ shared/models/applications.model.ts | 1 - shared/routes/recruiters.routes.ts | 1 + .../InformationCreationCompte.tsx | 7 +++--- 13 files changed, 50 insertions(+), 28 deletions(-) diff --git a/cypress/e2e/create-recruiter-account-manual-validation.cy.ts b/cypress/e2e/create-recruiter-account-manual-validation.cy.ts index e5ccb6fa27..23cfc2dda2 100644 --- a/cypress/e2e/create-recruiter-account-manual-validation.cy.ts +++ b/cypress/e2e/create-recruiter-account-manual-validation.cy.ts @@ -4,8 +4,8 @@ import { FlowCreationEntreprise } from "../pages/FlowCreationEntreprise" import { JobPage } from "../pages/JobPage" import { generateRandomString } from "../utils/generateRandomString" -describe("create-recruiter-account-siret-inexistent", () => { - it("tests create-recruiter-account-siret-inexistent", () => { +describe("create-recruiter-account-manual-validation", () => { + it("tests create-recruiter-account-manual-validation", () => { cy.viewport(1271, 721) const email = `cypress-manual-validation-${generateRandomString()}@mail.com` diff --git a/cypress/pages/SearchForm.ts b/cypress/pages/SearchForm.ts index 05434f2826..818569ee8e 100644 --- a/cypress/pages/SearchForm.ts +++ b/cypress/pages/SearchForm.ts @@ -39,6 +39,6 @@ export const SearchForm = { cy.get("[data-testid='widget-form'] button").click() }, uncheckFormations() { - cy.get("[data-testid='checkbox-filter-trainings']").click() + cy.get("[data-testid='checkbox-filter-trainings']").click({ multiple: true }) }, } diff --git a/server/src/http/controllers/etablissementRecruteur.controller.ts b/server/src/http/controllers/etablissementRecruteur.controller.ts index 33f0cdcf50..9d396321c7 100644 --- a/server/src/http/controllers/etablissementRecruteur.controller.ts +++ b/server/src/http/controllers/etablissementRecruteur.controller.ts @@ -169,7 +169,7 @@ export default (server: Server) => { else throw Boom.badRequest(result.message, result) } const token = generateDepotSimplifieToken(user2ToUserForToken(result.user), result.formulaire.establishment_id) - return res.status(200).send({ formulaire: result.formulaire, user: result.user, token }) + return res.status(200).send({ formulaire: result.formulaire, user: result.user, token, validated: result.validated }) } case CFA: { const { email, establishment_siret } = req.body @@ -290,7 +290,7 @@ export default (server: Server) => { await updateLastConnectionDate(email) await startSession(email, res) - return res.status(200).send(toPublicUser(user, await getPublicUserRecruteurPropsOrError(user._id))) + return res.status(200).send(toPublicUser(user, await getPublicUserRecruteurPropsOrError(user._id, true))) } ) } diff --git a/server/src/http/controllers/jobs.controller.ts b/server/src/http/controllers/jobs.controller.ts index 8912c3c5c8..2c834ff731 100644 --- a/server/src/http/controllers/jobs.controller.ts +++ b/server/src/http/controllers/jobs.controller.ts @@ -3,6 +3,7 @@ import { IJob, JOB_STATUS, zRoutes } from "shared" import { getUserFromRequest } from "@/security/authenticationService" import { Appellation } from "@/services/rome.service.types" +import { getUser2ByEmail } from "@/services/user2.service" import { Recruiter } from "../../common/model/index" import { getNearEtablissementsFromRomes } from "../../services/catalogue.service" @@ -133,6 +134,10 @@ export default (server: Server) => { if (!establishmentExists) { return res.status(400).send({ error: true, message: "Establishment does not exist" }) } + const user = await getUser2ByEmail(establishmentExists.email) + if (!user) { + return res.status(400).send({ error: true, message: "User does not exist" }) + } const romeDetails = await getFicheMetierRomeV3FromDB({ query: { @@ -165,6 +170,7 @@ export default (server: Server) => { job_rythm: body.job_rythm, custom_address: body.custom_address, custom_geo_coordinates: body.custom_geo_coordinates, + managed_by: user._id, } const updatedRecruiter = await createOffre(establishmentId, job) diff --git a/server/src/jobs/lba_recruteur/user/misc/updateSiretInfosInError.ts b/server/src/jobs/lba_recruteur/user/misc/updateSiretInfosInError.ts index d0e0ea5a23..ac4ba78cbe 100644 --- a/server/src/jobs/lba_recruteur/user/misc/updateSiretInfosInError.ts +++ b/server/src/jobs/lba_recruteur/user/misc/updateSiretInfosInError.ts @@ -13,7 +13,7 @@ import { asyncForEach } from "../../../../common/utils/asyncUtils" import { sentryCaptureException } from "../../../../common/utils/sentryUtils" import { notifyToSlack } from "../../../../common/utils/slackUtils" import { ENTREPRISE } from "../../../../services/constant.service" -import { EntrepriseData, autoValidateCompany, getEntrepriseDataFromSiret, sendEmailConfirmationEntreprise } from "../../../../services/etablissement.service" +import { EntrepriseData, autoValidateUserRoleOnCompany, getEntrepriseDataFromSiret, sendEmailConfirmationEntreprise } from "../../../../services/etablissement.service" import { activateEntrepriseRecruiterForTheFirstTime, archiveFormulaire, sendMailNouvelleOffre, updateFormulaire } from "../../../../services/formulaire.service" import { UserAndOrganization, deactivateEntreprise, setEntrepriseInError } from "../../../../services/userRecruteur.service" @@ -47,7 +47,7 @@ const updateEntreprisesInfosInError = async () => { await Promise.all( users.map(async (user) => { const userAndOrganization: UserAndOrganization = { user, type: ENTREPRISE, organization: updatedEntreprise } - const result = await autoValidateCompany(userAndOrganization) + const result = await autoValidateUserRoleOnCompany(userAndOrganization) if (result.validated) { const recruiter = recruiters.find((recruiter) => recruiter.email === user.email && recruiter.establishment_siret === siret) if (!recruiter) { diff --git a/server/src/security/authorisationService.ts b/server/src/security/authorisationService.ts index 2efd2109be..7e86aa3977 100644 --- a/server/src/security/authorisationService.ts +++ b/server/src/security/authorisationService.ts @@ -300,7 +300,6 @@ export async function authorizationMiddleware { +export const autoValidateUserRoleOnCompany = async (userAndEntreprise: UserAndOrganization) => { const validated = await isCompanyValid(userAndEntreprise) if (validated) { - await autoValidateUser(userAndEntreprise) + await authorizeUserOnEntreprise(userAndEntreprise) } else { await setUserHasToBeManuallyValidated(userAndEntreprise) } @@ -596,7 +597,6 @@ export const isCompanyValid = async (props: UserAndOrganization): Promise validEmail && isEmailSameDomain(email, validEmail))) if (isValid) { return true @@ -758,7 +758,7 @@ export const entrepriseOnboardingWorkflow = { }: { isUserValidated?: boolean } = {} - ): Promise => { + ): Promise => { const cfaErrorOpt = await validateCreationEntrepriseFromCfa({ siret, cfa_delegated_siret }) if (cfaErrorOpt) return cfaErrorOpt const formatedEmail = email.toLocaleLowerCase() @@ -797,14 +797,21 @@ export const entrepriseOnboardingWorkflow = { is_qualiopi: false, }) + let validated = false if (hasSiretError) { await setEntrepriseInError(creationResult.organization._id, "Erreur lors de l'appel à l'API SIRET") - } else if (isUserValidated) { - await autoValidateUser(creationResult) } else { - await autoValidateCompany(creationResult) + await setEntrepriseValid(creationResult.organization._id) + if (isUserValidated) { + await authorizeUserOnEntreprise(creationResult) + validated = true + } else { + const result = await autoValidateUserRoleOnCompany(creationResult) + validated = result.validated + } } - return { formulaire: formulaireInfo, user: creationResult.user } + + return { formulaire: formulaireInfo, user: creationResult.user, validated } }, createFromCFA: async ({ email, diff --git a/server/src/services/formulaire.service.ts b/server/src/services/formulaire.service.ts index c3461d5fd0..d7ca4fb3c2 100644 --- a/server/src/services/formulaire.service.ts +++ b/server/src/services/formulaire.service.ts @@ -146,11 +146,11 @@ export const createJob = async ({ job, establishment_id, user }: { job: IJobWrit throw Boom.internal("unexpected: no job found after job creation") } // if first offer creation for an Entreprise, send specific mail - if (jobs.length === 1 && is_delegated === false && isJobActive) { + if (jobs.length === 1 && is_delegated === false) { if (!entrepriseStatus) { throw Boom.internal(`inattendu : pas de status pour l'entreprise pour establishment_id=${establishment_id}`) } - const role = await RoleManagement.findOne({ userId, authorized_type: AccessEntityType.ENTREPRISE, authorized_id: organization._id.toString() }).lean() + const role = await RoleManagement.findOne({ user_id: userId, authorized_type: AccessEntityType.ENTREPRISE, authorized_id: organization._id.toString() }).lean() const roleStatus = getLastStatusEvent(role?.status)?.status ?? null await sendEmailConfirmationEntreprise(user, updatedFormulaire, roleStatus, entrepriseStatus) return updatedFormulaire diff --git a/server/src/services/roleManagement.service.ts b/server/src/services/roleManagement.service.ts index 7678fabf4b..a30d9b0819 100644 --- a/server/src/services/roleManagement.service.ts +++ b/server/src/services/roleManagement.service.ts @@ -46,8 +46,12 @@ export const getGrantedRoles = async (userId: string) => { } // TODO à supprimer lorsque les utilisateurs pourront avoir plusieurs types -export const getMainRoleManagement = async (userId: string | ObjectId): Promise => { - const roles = await getGrantedRoles(userId.toString()) +export const getMainRoleManagement = async (userId: string | ObjectId, includeUserAwaitingValidation: boolean = false): Promise => { + let roles = await getGrantedRoles(userId.toString()) + if (includeUserAwaitingValidation) { + const awaitingRoles = await RoleManagement.find({ user_id: userId, $expr: { $eq: [{ $arrayElemAt: ["$status.status", -1] }, AccessStatus.AWAITING_VALIDATION] } }).lean() + roles = roles.concat(awaitingRoles) + } const adminRole = roles.find((role) => role.authorized_type === AccessEntityType.ADMIN) if (adminRole) return adminRole const opcoRole = roles.find((role) => role.authorized_type === AccessEntityType.OPCO) @@ -89,9 +93,10 @@ const roleToStatus = (role: IRoleManagement) => { } export const getPublicUserRecruteurPropsOrError = async ( - userId: string | ObjectId + userId: string | ObjectId, + includeUserAwaitingValidation: boolean = false ): Promise> => { - const mainRole = await getMainRoleManagement(userId) + const mainRole = await getMainRoleManagement(userId, includeUserAwaitingValidation) if (!mainRole) { throw Boom.internal(`inattendu : aucun role trouvé pour user id=${userId}`) } diff --git a/server/src/services/userRecruteur.service.ts b/server/src/services/userRecruteur.service.ts index 376d18d7c9..93a1870067 100644 --- a/server/src/services/userRecruteur.service.ts +++ b/server/src/services/userRecruteur.service.ts @@ -309,6 +309,10 @@ export const getUserStatus = (stateArray: IUserRecruteur["status"]): IUserStatus return lastValidationEvent.status } +export const setEntrepriseValid = async (entrepriseId: IEntreprise["_id"]) => { + return setEntrepriseStatus(entrepriseId, "", EntrepriseStatus.VALIDE) +} + export const setEntrepriseInError = async (entrepriseId: IEntreprise["_id"], reason: string) => { return setEntrepriseStatus(entrepriseId, reason, EntrepriseStatus.ERROR) } @@ -318,6 +322,8 @@ export const setEntrepriseStatus = async (entrepriseId: IEntreprise["_id"], reas if (!entreprise) { throw Boom.internal(`could not find entreprise with id=${entrepriseId}`) } + const lastStatus = getLastStatusEvent(entreprise.status)?.status + if (lastStatus === status && status === EntrepriseStatus.VALIDE) return const event: IEntrepriseStatusEvent = { date: new Date(), reason, diff --git a/shared/models/applications.model.ts b/shared/models/applications.model.ts index 0a6d8591df..4bd8b49960 100644 --- a/shared/models/applications.model.ts +++ b/shared/models/applications.model.ts @@ -9,7 +9,6 @@ import { zObjectId } from "./common" export const ZApplication = z .object({ _id: zObjectId, - applicant_id: zObjectId, applicant_email: z.string().email().openapi({ description: "L'adresse email du candidat à laquelle l'entreprise contactée pourra répondre. Les adresses emails temporaires ne sont pas acceptées.", example: "john.smith@mail.com", diff --git a/shared/routes/recruiters.routes.ts b/shared/routes/recruiters.routes.ts index 316fa92a70..35bcbaaebd 100644 --- a/shared/routes/recruiters.routes.ts +++ b/shared/routes/recruiters.routes.ts @@ -168,6 +168,7 @@ export const zRecruiterRoutes = { formulaire: ZRecruiter.optional(), user: ZUser2, token: z.string().optional(), + validated: z.boolean().optional(), }) .strict(), }, diff --git a/ui/components/espace_pro/Authentification/InformationCreationCompte.tsx b/ui/components/espace_pro/Authentification/InformationCreationCompte.tsx index 1525d67f86..d1db41328a 100644 --- a/ui/components/espace_pro/Authentification/InformationCreationCompte.tsx +++ b/ui/components/espace_pro/Authentification/InformationCreationCompte.tsx @@ -2,8 +2,7 @@ import { Box, Button, Flex, FormControl, FormErrorMessage, FormHelperText, FormL import { Form, Formik } from "formik" import { useRouter } from "next/router" import { useContext, useState } from "react" -import { IRecruiterJson, IUserStatusValidationJson } from "shared" -import { ETAT_UTILISATEUR } from "shared/constants/recruteur" +import { IRecruiterJson } from "shared" import { IUser2Json } from "shared/models/user2.model" import * as Yup from "yup" @@ -168,8 +167,8 @@ export const InformationCreationCompte = ({ isWidget = false }: { isWidget?: boo if (!data) { throw new Error("no data") } - const statusArray: IUserStatusValidationJson[] = data.user?.status ?? [] - if (statusArray?.at(0)?.status === ETAT_UTILISATEUR.VALIDE) { + const isValidated = data.validated + if (isValidated) { if (type === AUTHTYPE.ENTREPRISE) { // Dépot simplifié router.push({ From e6324831e234399497c6cd5590d8e80835895219 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9my=20Auricoste?= Date: Tue, 26 Mar 2024 17:18:18 +0100 Subject: [PATCH 58/78] fix: rename test cypress --- cypress/e2e/create-recruiter-account.cy.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cypress/e2e/create-recruiter-account.cy.ts b/cypress/e2e/create-recruiter-account.cy.ts index 7a9f1de848..309eba5df5 100644 --- a/cypress/e2e/create-recruiter-account.cy.ts +++ b/cypress/e2e/create-recruiter-account.cy.ts @@ -4,8 +4,8 @@ import { JobPage } from "../pages/JobPage" import { LoginBar } from "../pages/LoginBar" import { generateRandomString } from "../utils/generateRandomString" -describe("create-recruiter-account-siret-inexistent", () => { - it("test create-recruiter-account-siret-inexistent", () => { +describe("create-recruiter-account", () => { + it("test create-recruiter-account", () => { cy.viewport(1271, 721) const emailDomain = Cypress.env("ENTREPRISE_AUTOVALIDE_EMAIL_DOMAIN") From fd033f1b59b109c09063ea8e51dbc64d57f0a6f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9my=20Auricoste?= Date: Wed, 27 Mar 2024 10:40:54 +0100 Subject: [PATCH 59/78] fix: amelioration fiabilite test cypress --- cypress/pages/SearchForm.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cypress/pages/SearchForm.ts b/cypress/pages/SearchForm.ts index 818569ee8e..1d38ab9c4c 100644 --- a/cypress/pages/SearchForm.ts +++ b/cypress/pages/SearchForm.ts @@ -39,6 +39,6 @@ export const SearchForm = { cy.get("[data-testid='widget-form'] button").click() }, uncheckFormations() { - cy.get("[data-testid='checkbox-filter-trainings']").click({ multiple: true }) + cy.get("[data-testid='checkbox-filter-trainings']").click({ multiple: true, force: true }) }, } From 3d10e64dc95d9de0018a13a308b7bf0e2898c30b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9my=20Auricoste?= Date: Wed, 27 Mar 2024 12:34:13 +0100 Subject: [PATCH 60/78] fix: 1er jet tests authorization.service --- .talismanrc | 2 +- server/src/security/authorisationService.ts | 14 +- .../security/authorisationService.test.ts | 2205 ++--------------- server/tests/utils/user.utils.ts | 127 +- .../generateOpenapi.test.ts.snap | 3 + 5 files changed, 384 insertions(+), 1967 deletions(-) diff --git a/.talismanrc b/.talismanrc index 65b231a1ed..2065a87db9 100644 --- a/.talismanrc +++ b/.talismanrc @@ -68,7 +68,7 @@ fileignoreconfig: - filename: server/tests/unit/security/accessTokenService.test.ts checksum: 9c157ea55171c74fa50b493577f1529f5c5c005c8137b390c9dfa719f6ba87ba - filename: server/tests/unit/security/authorisationService.test.ts - checksum: 581074420be582973bbfcdfafe1f700ca32f56e331911609cdc1cb2fb2626383 + checksum: fa777cbd1d241d1d9d187f4fb7e882200b7221b9d4794233be95009d555f372e - filename: server/tests/unit/services/eligibleTrainingsForAppointment.service.test.ts checksum: 089218ccdaf2553d5a427cb81d0b556f51badaba5b5523299e5fe74e7ef5a802 - filename: server/tests/unit/util.test.ts diff --git a/server/src/security/authorisationService.ts b/server/src/security/authorisationService.ts index 7e86aa3977..29bf77aab2 100644 --- a/server/src/security/authorisationService.ts +++ b/server/src/security/authorisationService.ts @@ -14,13 +14,14 @@ import { Primitive } from "type-fest" import { Application, Cfa, Entreprise, Recruiter, User2 } from "@/common/model" import { getComputedUserAccess, getGrantedRoles } from "@/services/roleManagement.service" +import { getUser2ByEmail } from "@/services/user2.service" import { isUserEmailChecked } from "@/services/userRecruteur.service" import { getUserFromRequest } from "./authenticationService" type RecruiterResource = { recruiter: IRecruiter } & ({ type: "ENTREPRISE"; entreprise: IEntreprise } | { type: "CFA"; cfa: ICFA }) type JobResource = { job: IJob; recruiterResource: RecruiterResource } -type ApplicationResource = { application: IApplication; jobResource?: JobResource } +type ApplicationResource = { application: IApplication; jobResource?: JobResource; applicantId?: string } type Resources = { users: Array<{ _id: string }> @@ -173,7 +174,8 @@ async function getApplicationResource(schema: S, r return { application } } const jobResource = await jobToJobResource(job, recruiter) - return { application, jobResource } + const user = await getUser2ByEmail(application.applicant_email) + return { application, jobResource, applicantId: user?._id.toString() } } assertUnreachable(applicationDef) @@ -182,7 +184,7 @@ async function getApplicationResource(schema: S, r return results.flatMap((_) => (_ ? [_] : [])) } -export async function getResources(schema: S, req: IRequest): Promise { +async function getResources(schema: S, req: IRequest): Promise { const [recruiters, jobs, users, applications] = await Promise.all([ getRecruitersResource(schema, req), getJobsResource(schema, req), @@ -223,12 +225,12 @@ function canAccessUser(userAccess: ComputedUserAccess, resource: Resources["user } function canAccessApplication(userAccess: ComputedUserAccess, resource: Resources["applications"][number]): boolean { - const { application, jobResource } = resource + const { jobResource, applicantId } = resource // TODO ajout de granularité pour les accès candidat et recruteur - return (jobResource && canAccessJob(userAccess, jobResource)) || canAccessUser(userAccess, { _id: application.applicant_id.toString() }) + return (jobResource && canAccessJob(userAccess, jobResource)) || (applicantId ? canAccessUser(userAccess, { _id: applicantId }) : false) } -export function isAuthorized(access: AccessPermission, userAccess: ComputedUserAccess, resources: Resources): boolean { +function isAuthorized(access: AccessPermission, userAccess: ComputedUserAccess, resources: Resources): boolean { if (typeof access === "object") { if ("some" in access) { return access.some.some((permission) => isAuthorized(permission, userAccess, resources)) diff --git a/server/tests/unit/security/authorisationService.test.ts b/server/tests/unit/security/authorisationService.test.ts index 647ebd34a5..18e0806639 100644 --- a/server/tests/unit/security/authorisationService.test.ts +++ b/server/tests/unit/security/authorisationService.test.ts @@ -1,1991 +1,304 @@ import { FastifyRequest } from "fastify" -import { LBA_ITEM_TYPE } from "shared/constants/lbaitem" -import { ETAT_UTILISATEUR } from "shared/constants/recruteur" -import { IApplication, ICredential, IJob, IRecruiter, IUserRecruteur } from "shared/models" +import { OPCOS, RECRUITER_STATUS, VALIDATION_UTILISATEUR } from "shared/constants/recruteur" +import { AccessEntityType, AccessStatus, IRoleManagementEvent } from "shared/models/roleManagement.model" import { IUser2 } from "shared/models/user2.model" -import { SecurityScheme } from "shared/routes/common.routes" -import { AccessPermission, AccessRessouces, Permission } from "shared/security/permissions" +import { AuthStrategy, IRouteSchema, WithSecurityScheme } from "shared/routes/common.routes" +import { AccessRessouces } from "shared/security/permissions" import { describe, expect, it } from "vitest" -import { Application, Recruiter, UserRecruteur } from "@/common/model" -import { ObjectId } from "@/common/mongodb" -import { generateScope } from "@/security/accessTokenService" -import { AccessUserToken } from "@/security/authenticationService" +import { AccessUser2, AccessUserCredential, AccessUserToken } from "@/security/authenticationService" import { authorizationMiddleware } from "@/security/authorisationService" import { useMongo } from "@tests/utils/mongo.utils" -import { createApplicationTest, createCredentialTest, createRecruteurTest, createUserRecruteurTest } from "../../utils/user.utils" - -describe("authorisationService", () => { - let adminUser: IUser2 - let opcoUserO1U1: IUser2 - let opcoUserO1U2: IUser2 - let cfaUser1: IUser2 - let cfaUser2: IUser2 - let recruteurUserO1E1R1: IUser2 - let recruteurO1E1R1: IRecruiter - let recruteurUserO1E1R2: IUser2 - let recruteurO1E1R2: IRecruiter - let recruteurUserO1E2R1: IUser2 - let recruteurO1E2R1: IRecruiter - let opcoUserO2U1: IUser2 - let recruteurUserO2E1R1: IUser2 - let recruteurO2E1R1: IRecruiter - let recruteurUserO2E1R1P: IUser2 - let recruteurO2E1R1P: IRecruiter - let credentialO1: ICredential - let applicationO1E1R1J1A1: IApplication - let applicationO1E1R1J1A2: IApplication - let applicationO1E1R1J2A1: IApplication - let applicationO1E1R2J1A1: IApplication - - function getResourceAccessKey(resource: IUserRecruteur | IRecruiter | IJob | IApplication, i: number): string { - if (resource instanceof UserRecruteur) { - return `userId${i}` - } - if (resource instanceof Recruiter) { - return `recruiterId${i}` - } - if (resource instanceof Application) { - return `applicationId${i}` - } - - return `jobId${i}` - } - - function generateSecuritySchemeFixture( - access: AccessPermission, - resources: ReadonlyArray, - location: "params" | "query" - ): [SecurityScheme, Pick] { - return [ - { - auth: "cookie-session", - access, - resources: resources.reduce((acc, resource, i) => { - const key = getResourceAccessKey(resource, i) - if (resource instanceof UserRecruteur) { - const user = acc.user ?? [] - acc.user = [...user, { _id: { type: location, key } }] - return { - ...acc, - user: [...user, { _id: { type: location, key } }], - } - } - if (resource instanceof Recruiter) { - const recruiter = acc.recruiter ?? [] - return { - ...acc, - recruiter: [...recruiter, { _id: { type: location, key } }], - } - } - if (resource instanceof Application) { - const application = acc.application ?? [] - return { - ...acc, - application: [...application, { _id: { type: location, key } }], - } +import { jobFactory, saveCfa, saveEntreprise, saveRecruiter, saveRoleManagement, saveUser2 } from "../../utils/user.utils" + +type MockedRequest = Pick +const emptyRequest: MockedRequest = { params: {}, query: {} } + +type ResourceType = keyof AccessRessouces + +const givenARoute = ({ + authStrategy, + resourceType, + hasRequestedAccess = true, +}: { + authStrategy: AuthStrategy + resourceType?: ResourceType + hasRequestedAccess?: boolean +}): Pick & WithSecurityScheme => { + // TODO formationCatalogue don't have an _id + return { + method: "get", + path: "/path", + securityScheme: { + access: hasRequestedAccess ? "recruiter:manage" : null, + auth: authStrategy, + resources: resourceType + ? { + [resourceType]: [{ _id: { type: "query", key: "resourceId" } }], } - const job = acc.job ?? [] - return { - ...acc, - job: [...job, { _id: { type: location, key } }], - } - }, {} as AccessRessouces), - }, - resources.reduce( - (acc, resource, i) => { - const p = acc[location] ?? {} - p[getResourceAccessKey(resource, i)] = resource._id - acc[location] = p - - return acc - }, - {} as Record<"params" | "query", Record> - ), - ] + : {}, + }, } - - const mockData = async () => { - // Here is the overall relation we will use to test permissions - - // CfaUser #1 - // CfaUser #2 - // OPCO #O1 - // |--- OpcoUser #O1#U1 - // |--- OpcoUser #O1#U2 - // |--- Entreprise #O1#E1 - // --> Recruteur #O1#E1#R1 --> Delegated #01#U3 - // ——> Recruteur pending validation #O2#E1#R1#P - // --> Job #O1#E1#R1#J1 - // --> Application #O1#E1#R1#J1#A1 - // --> Application #O1#E1#R1#J1#A2 - // --> Job #O1#E1#R1#J2 - // --> Application #O1#E1#R1#J2#A1 - // --> Recruteur #O1#E1#R2 - // --> Job #O1#E1#R2#J1 - // --> Application #O1#E1#R2#J1#A1 - // --> Job #O1#E1#R2#J2 - - // |--- Entreprise #O1#E2 - // --> Recruteur #O1#E2#R1 - // --> Job #O1#E2#R1#J1 - // OPCO #O2 - // |--- Entreprise #O2#E1 - // --> Recruteur #O2#E1#R1 - // --> Job #O2#E1#R1#J1 - // |--- OpcoUser #O2#U1 - - const CFA_SIRET = "80300515600044" - const O1E1R1J1Id = new ObjectId() - const O1E1R1J2Id = new ObjectId() - const O1E1R2J1Id = new ObjectId() - const O1E1Siret = "88160687500014" - const O1E2Siret = "38959133000060" - - adminUser = await createUserRecruteurTest({ - type: "ADMIN", - }) - - opcoUserO1U1 = await createUserRecruteurTest({ - type: "OPCO", - scope: "#O1", - first_name: "O1U1", - }) - opcoUserO1U2 = await createUserRecruteurTest({ - type: "OPCO", - scope: "#O1", - first_name: "O1U2", - }) - cfaUser1 = await createUserRecruteurTest({ - type: "CFA", - first_name: "O1", - establishment_siret: CFA_SIRET, - }) - - recruteurUserO1E1R1 = await createUserRecruteurTest({ - type: "ENTREPRISE", - opco: "#O1", - establishment_id: "#O1#E1#R1", - establishment_siret: O1E1Siret, - }) - recruteurO1E1R1 = await createRecruteurTest( - { - opco: "#O1", - establishment_id: "#O1#E1#R1", - cfa_delegated_siret: CFA_SIRET, - establishment_siret: O1E1Siret, - }, - [ - { _id: O1E1R1J1Id, job_description: "#O1#E1#R1#J1" }, - { _id: O1E1R1J2Id, job_description: "#O1#E1#R1#J2" }, - ] - ) - applicationO1E1R1J1A1 = await createApplicationTest({ - job_id: O1E1R1J1Id.toString(), - job_origin: LBA_ITEM_TYPE.OFFRES_EMPLOI_LBA, - applicant_message_to_company: "#O1#E1#R1#J1#A1", - }) - applicationO1E1R1J1A2 = await createApplicationTest({ - job_id: O1E1R1J1Id.toString(), - job_origin: LBA_ITEM_TYPE.OFFRES_EMPLOI_LBA, - applicant_message_to_company: "#O1#E1#R1#J1#A2", - }) - applicationO1E1R1J2A1 = await createApplicationTest({ - job_id: O1E1R1J2Id.toString(), - job_origin: LBA_ITEM_TYPE.OFFRES_EMPLOI_LBA, - applicant_message_to_company: "#O1#E1#R1#J2#A1", - }) - - recruteurUserO1E1R2 = await createUserRecruteurTest({ - type: "ENTREPRISE", - opco: "#O1", - establishment_id: "#O1#E1#R2", - establishment_siret: O1E1Siret, - }) - recruteurO1E1R2 = await createRecruteurTest( - { - opco: "#O1", - establishment_id: "#O1#E1#R2", - establishment_siret: O1E1Siret, - }, - [{ _id: O1E1R2J1Id, job_description: "#O1#E1#R2#J1" }] - ) - applicationO1E1R2J1A1 = await createApplicationTest({ - job_id: O1E1R2J1Id.toString(), - job_origin: LBA_ITEM_TYPE.OFFRES_EMPLOI_LBA, - applicant_message_to_company: "#O1#E1#R2#J1#A1", - }) - - recruteurUserO1E2R1 = await createUserRecruteurTest({ - type: "ENTREPRISE", - opco: "#O1", - establishment_id: "#O1#E2#R1", - establishment_siret: O1E2Siret, - }) - recruteurO1E2R1 = await createRecruteurTest( - { - opco: "#O1", - establishment_id: "#O1#E2#R1", - establishment_siret: O1E2Siret, - }, - [{ job_description: "#O1#E2#R1#J1" }] - ) - - opcoUserO2U1 = await createUserRecruteurTest({ - type: "OPCO", - scope: "#O2", - first_name: "O2U1", - }) - cfaUser2 = await createUserRecruteurTest({ - type: "CFA", - scope: "#O2", - first_name: "O2", - }) - - recruteurUserO2E1R1 = await createUserRecruteurTest({ - type: "ENTREPRISE", - scope: "#O2", - establishment_id: "#O2#E1#R1", - }) - recruteurO2E1R1 = await createRecruteurTest( - { - opco: "#O2", - establishment_id: "#O2#E1#R1", - }, - [{ job_description: "#O2#E1#R1#J1" }] - ) - - recruteurUserO2E1R1P = await createUserRecruteurTest( - { - type: "ENTREPRISE", - scope: "#O2", - establishment_id: "#O2#E1#R1P", - }, - ETAT_UTILISATEUR.ATTENTE - ) - recruteurO2E1R1P = await createRecruteurTest( - { - opco: "#O2", - establishment_id: "#O2#E1#R1P", +} + +const everyResourceType: ResourceType[] = ["application", "appointment", "formationCatalogue", "job", "recruiter", "user"] +const everyAuthStrategy: AuthStrategy[] = ["access-token", "api-key", "cookie-session"] + +const roleManagementEventFactory = ({ + date = new Date(), + granted_by, + reason = "reason", + status = AccessStatus.GRANTED, + validation_type = VALIDATION_UTILISATEUR.AUTO, +}: Partial = {}): IRoleManagementEvent => { + return { + date, + granted_by, + reason, + status, + validation_type, + } +} + +const createAdminUser = async () => { + const user = await saveUser2() + const role = await saveRoleManagement({ + user_id: user._id, + authorized_type: AccessEntityType.ADMIN, + authorized_id: undefined, + status: [roleManagementEventFactory()], + }) + return { user, role } +} + +const createEntrepriseUser = async () => { + const user = await saveUser2() + const entreprise = await saveEntreprise() + const role = await saveRoleManagement({ + user_id: user._id, + authorized_id: entreprise._id.toString(), + authorized_type: AccessEntityType.ENTREPRISE, + status: [roleManagementEventFactory()], + }) + const recruiter = await saveRecruiter({ + is_delegated: false, + cfa_delegated_siret: null, + status: RECRUITER_STATUS.ACTIF, + establishment_siret: entreprise.siret, + opco: entreprise.opco, + jobs: [ + jobFactory({ + managed_by: user._id, + }), + ], + }) + return { user, role, entreprise, recruiter } +} + +const createCfaUser = async () => { + const user = await saveUser2() + const cfa = await saveCfa() + const role = await saveRoleManagement({ + user_id: user._id, + authorized_id: cfa._id.toString(), + authorized_type: AccessEntityType.CFA, + status: [roleManagementEventFactory()], + }) + const recruiter = await saveRecruiter({ + is_delegated: true, + cfa_delegated_siret: cfa.siret, + status: RECRUITER_STATUS.ACTIF, + jobs: [ + jobFactory({ + managed_by: user._id, + }), + ], + }) + return { user, role, cfa, recruiter } +} + +const createOpcoUser = async () => { + const user = await saveUser2() + const role = await saveRoleManagement({ + user_id: user._id, + authorized_id: OPCOS.AKTO, + authorized_type: AccessEntityType.OPCO, + status: [roleManagementEventFactory()], + }) + return { user, role } +} + +const givenATokenUser = (): AccessUserToken => { + return { + type: "IAccessToken", + value: { + identity: { + _id: "userId", + email: "email@mail.com", + type: "IUserRecruteur", }, - [{ job_description: "#O2#E1#R1#J1P" }] - ) - - credentialO1 = await createCredentialTest({ - organisation: "#O1", - }) + scopes: [], + }, + } +} +// const givenACredentialUser = (): AccessUserCredential => { +// return { +// type: "ICredential", +// value: {}, +// } +// } + +const givenARequest = ({ user, resourceId }: { user: AccessUserToken | AccessUserCredential | AccessUser2; resourceId?: string }) => { + return { + ...emptyRequest, + user, + ...(resourceId ? { query: { resourceId } } : {}), + } +} + +describe("authorisationService", async () => { + let adminUser: Awaited> + let entrepriseUserA: Awaited> + let entrepriseUserB: Awaited> + let cfaUserA: Awaited> + let cfaUserB: Awaited> + let opcoUserA: Awaited> + + useMongo(async () => { + adminUser = await createAdminUser() + entrepriseUserA = await createEntrepriseUser() + entrepriseUserB = await createEntrepriseUser() + cfaUserA = await createCfaUser() + cfaUserB = await createCfaUser() + opcoUserA = await createOpcoUser() + }, "beforeAll") + + const givenACookieUser = (user: IUser2): AccessUser2 => { + return { + type: "IUser2", + value: user, + } } - useMongo(mockData, "beforeAll") - - describe.each<["params" | "query"]>([["params"], ["query"]])("when resources are identified in %s", (location) => { - describe("as an admin user", () => { - describe.each<[Permission]>([ - ["recruiter:manage"], - ["user:validate"], - ["recruiter:add_job"], - ["job:manage"], - ["school:manage"], - ["application:manage"], - ["user:manage"], - ["admin"], - ])("I have %s permission", (permission) => { - it("on all ressources", async () => { - const [securityScheme, req] = generateSecuritySchemeFixture( - permission, - [ - adminUser, - opcoUserO1U1, - opcoUserO1U2, - cfaUser2, - recruteurUserO1E1R1, - recruteurO1E1R1, - ...recruteurO1E1R1.jobs, - recruteurUserO1E1R2, - recruteurO1E1R2, - ...recruteurO1E1R2.jobs, - recruteurUserO1E2R1, - recruteurO1E2R1, - ...recruteurO1E2R1.jobs, - opcoUserO2U1, - cfaUser1, - recruteurUserO2E1R1, - recruteurO2E1R1, - ...recruteurO2E1R1.jobs, - applicationO1E1R1J1A1, - applicationO1E1R1J1A2, - applicationO1E1R1J2A1, - ], - location - ) - await expect( - authorizationMiddleware( - { - method: "get", - path: "/path", - securityScheme, - }, - { - user: { - type: "IUser2", - value: adminUser, - }, - ...req, - } - ) - ).resolves.toBe(undefined) - }) - }) - }) - - describe("as an opco user", () => { - describe.each<[Permission]>([["recruiter:manage"], ["user:validate"], ["recruiter:add_job"]])("I have %s permission", (permission) => { - it("on all recruiters from my opco", async () => { - const [securityScheme, req] = generateSecuritySchemeFixture(permission, [recruteurO1E1R1, recruteurO1E1R2, recruteurO1E2R1], location) - await expect( - authorizationMiddleware( - { - method: "get", - path: "/path", - securityScheme, - }, - { - user: { - type: "IUser2", - value: opcoUserO1U1, - }, - ...req, - } - ) - ).resolves.toBe(undefined) - }) - }) - describe.each<[Permission]>([["job:manage"]])("I have %s permission", (permission) => { - it("on all jobs from my opco", async () => { - const [securityScheme, req] = generateSecuritySchemeFixture(permission, [...recruteurO1E1R1.jobs, ...recruteurO1E1R2.jobs, ...recruteurO1E2R1.jobs], location) - await expect( - authorizationMiddleware( - { - method: "get", - path: "/path", - securityScheme, - }, - { - user: { - type: "IUser2", - value: opcoUserO1U1, - }, - ...req, - } - ) - ).resolves.toBe(undefined) - }) - }) - describe.each<[Permission]>([["user:manage"]])("I have %s permission", (permission) => { - it("on myself", async () => { - const [securityScheme, req] = generateSecuritySchemeFixture(permission, [opcoUserO1U1], location) - await expect( - authorizationMiddleware( - { - method: "get", - path: "/path", - securityScheme, - }, - { - user: { - type: "IUser2", - value: opcoUserO1U1, - }, - ...req, - } - ) - ).resolves.toBe(undefined) - }) - }) - describe.each<[Permission]>([["user:manage"], ["user:validate"]])("I have %s permission", (permission) => { - it("on user recruiter from my Opco", async () => { - const [securityScheme, req] = generateSecuritySchemeFixture(permission, [recruteurUserO1E1R1], location) - await expect( - authorizationMiddleware( - { - method: "get", - path: "/path", - securityScheme, - }, - { - user: { - type: "IUser2", - value: opcoUserO1U1, - }, - ...req, - } - ) - ).resolves.toBe(undefined) - }) - }) - - describe.each<[Permission]>([["recruiter:manage"], ["recruiter:add_job"], ["admin"]])("I do not have %s permission", (permission) => { - it("on recruiter from other Opco", async () => { - const [securityScheme, req] = generateSecuritySchemeFixture(permission, [recruteurO2E1R1], location) - await expect( - authorizationMiddleware( - { - method: "get", - path: "/path", - securityScheme, - }, - { - user: { - type: "IUser2", - value: opcoUserO1U1, - }, - ...req, - } - ) - ).rejects.toThrow("Forbidden") - }) - }) - describe.each<[Permission]>([["job:manage"], ["admin"]])("I do not have %s permission", (permission) => { - it("on jobs from other Opco", async () => { - const [securityScheme, req] = generateSecuritySchemeFixture(permission, [...recruteurO2E1R1.jobs], location) - await expect( - authorizationMiddleware( - { - method: "get", - path: "/path", - securityScheme, - }, - { - user: { - type: "IUser2", - value: opcoUserO1U1, - }, - ...req, - } - ) - ).rejects.toThrow("Forbidden") - }) - }) - - describe.each<[Permission]>([["school:manage"], ["admin"]])("I do not have %s permission", (permission) => { - it("on school", async () => { - const [securityScheme, req] = generateSecuritySchemeFixture(permission, [cfaUser1], location) - await expect( - authorizationMiddleware( - { - method: "get", - path: "/path", - securityScheme, - }, - { - user: { - type: "IUser2", - value: opcoUserO1U1, - }, - ...req, - } - ) - ).rejects.toThrow("Forbidden") - }) - }) - - describe.each<[Permission]>([["application:manage"], ["admin"]])("I do not have %s permission", (permission) => { - it("on application from my Opco", async () => { - const [securityScheme, req] = generateSecuritySchemeFixture(permission, [applicationO1E1R1J1A1], location) - await expect( - authorizationMiddleware( - { - method: "get", - path: "/path", - securityScheme, - }, - { - user: { - type: "IUser2", - value: opcoUserO1U1, - }, - ...req, - } - ) - ).rejects.toThrow("Forbidden") - }) - }) - - describe.each<[Permission]>([["admin"]])("I do not have %s permission", (permission) => { - it("on user recruiter from my Opco", async () => { - const [securityScheme, req] = generateSecuritySchemeFixture(permission, [recruteurUserO1E1R1], location) - await expect( - authorizationMiddleware( - { - method: "get", - path: "/path", - securityScheme, - }, - { - user: { - type: "IUser2", - value: opcoUserO1U1, - }, - ...req, - } - ) - ).rejects.toThrow("Forbidden") - }) - }) - - describe.each<[Permission]>([["user:manage"], ["admin"], ["user:validate"]])("I do not have %s permission", (permission) => { - it("on admin user", async () => { - const [securityScheme, req] = generateSecuritySchemeFixture(permission, [adminUser], location) - await expect( - authorizationMiddleware( - { - method: "get", - path: "/path", - securityScheme, - }, - { - user: { - type: "IUser2", - value: opcoUserO1U1, - }, - ...req, - } - ) - ).rejects.toThrow("Forbidden") - }) - }) - - describe.each<[Permission]>([["user:manage"], ["admin"], ["user:validate"]])("I do not have %s permission", (permission) => { - it("on user CFA", async () => { - const [securityScheme, req] = generateSecuritySchemeFixture(permission, [cfaUser1], location) - await expect( - authorizationMiddleware( - { - method: "get", - path: "/path", - securityScheme, - }, - { - user: { - type: "IUser2", - value: opcoUserO1U1, - }, - ...req, - } - ) - ).rejects.toThrow("Forbidden") - }) - }) - - describe.each<[Permission]>([["user:manage"], ["admin"], ["user:validate"]])("I do not have %s permission", (permission) => { - it("on other user Opco from my Opco", async () => { - const [securityScheme, req] = generateSecuritySchemeFixture(permission, [opcoUserO1U2], location) - await expect( - authorizationMiddleware( - { - method: "get", - path: "/path", - securityScheme, - }, - { - user: { - type: "IUser2", - value: opcoUserO1U1, - }, - ...req, - } - ) - ).rejects.toThrow("Forbidden") - }) - }) - }) - - describe("as an opco credential", () => { - describe.each<[Permission]>([["recruiter:manage"], ["user:validate"], ["recruiter:add_job"]])("I have %s permission", (permission) => { - it("on all recruiters from my opco", async () => { - const [securityScheme, req] = generateSecuritySchemeFixture(permission, [recruteurO1E1R1, recruteurO1E1R2, recruteurO1E2R1], location) - await expect( - authorizationMiddleware( - { - method: "get", - path: "/path", - securityScheme, - }, - { - user: { - type: "ICredential", - value: credentialO1, - }, - ...req, - } - ) - ).resolves.toBe(undefined) - }) - }) - describe.each<[Permission]>([["job:manage"]])("I have %s permission", (permission) => { - it("on all jobs from my opco", async () => { - const [securityScheme, req] = generateSecuritySchemeFixture(permission, [...recruteurO1E1R1.jobs, ...recruteurO1E1R2.jobs, ...recruteurO1E2R1.jobs], location) - await expect( - authorizationMiddleware( - { - method: "get", - path: "/path", - securityScheme, - }, - { - user: { - type: "ICredential", - value: credentialO1, - }, - ...req, - } - ) - ).resolves.toBe(undefined) - }) - }) - describe.each<[Permission]>([["user:manage"]])("I have %s permission", (permission) => { - it("on myself", async () => { - const [securityScheme, req] = generateSecuritySchemeFixture(permission, [opcoUserO1U1], location) - await expect( - authorizationMiddleware( - { - method: "get", - path: "/path", - securityScheme, - }, - { - user: { - type: "ICredential", - value: credentialO1, - }, - ...req, - } - ) - ).resolves.toBe(undefined) - }) - }) - - describe.each<[Permission]>([["recruiter:manage"], ["recruiter:add_job"], ["admin"]])("I do not have %s permission", (permission) => { - it("on recruiter from other Opco", async () => { - const [securityScheme, req] = generateSecuritySchemeFixture(permission, [recruteurO2E1R1], location) - await expect( - authorizationMiddleware( - { - method: "get", - path: "/path", - securityScheme, - }, - { - user: { - type: "ICredential", - value: credentialO1, - }, - ...req, - } - ) - ).rejects.toThrow("Forbidden") - }) - }) - describe.each<[Permission]>([["job:manage"], ["admin"]])("I do not have %s permission", (permission) => { - it("on jobs from other Opco", async () => { - const [securityScheme, req] = generateSecuritySchemeFixture(permission, [...recruteurO2E1R1.jobs], location) - await expect( - authorizationMiddleware( - { - method: "get", - path: "/path", - securityScheme, - }, - { - user: { - type: "ICredential", - value: credentialO1, - }, - ...req, - } - ) - ).rejects.toThrow("Forbidden") - }) - }) - - describe.each<[Permission]>([["school:manage"], ["admin"]])("I do not have %s permission", (permission) => { - it("on school", async () => { - const [securityScheme, req] = generateSecuritySchemeFixture(permission, [cfaUser1], location) - await expect( - authorizationMiddleware( - { - method: "get", - path: "/path", - securityScheme, - }, - { - user: { - type: "ICredential", - value: credentialO1, - }, - ...req, - } - ) - ).rejects.toThrow("Forbidden") - }) - }) - - describe.each<[Permission]>([["application:manage"], ["admin"]])("I do not have %s permission", (permission) => { - it("on application from my Opco", async () => { - const [securityScheme, req] = generateSecuritySchemeFixture(permission, [applicationO1E1R1J1A1], location) - await expect( - authorizationMiddleware( - { - method: "get", - path: "/path", - securityScheme, - }, - { - user: { - type: "ICredential", - value: credentialO1, - }, - ...req, - } - ) - ).rejects.toThrow("Forbidden") - }) - }) - - describe.each<[Permission]>([["user:manage"], ["admin"]])("I do not have %s permission", (permission) => { - it("on user recruiter from my Opco", async () => { - const [securityScheme, req] = generateSecuritySchemeFixture(permission, [recruteurUserO1E1R1], location) - await expect( - authorizationMiddleware( - { - method: "get", - path: "/path", - securityScheme, - }, - { - user: { - type: "ICredential", - value: credentialO1, - }, - ...req, - } - ) - ).rejects.toThrow("Forbidden") - }) - }) - - describe.each<[Permission]>([["user:manage"], ["admin"]])("I do not have %s permission", (permission) => { - it("on admin user", async () => { - const [securityScheme, req] = generateSecuritySchemeFixture(permission, [adminUser], location) - await expect( - authorizationMiddleware( - { - method: "get", - path: "/path", - securityScheme, - }, - { - user: { - type: "ICredential", - value: credentialO1, - }, - ...req, - } - ) - ).rejects.toThrow("Forbidden") - }) - }) - - describe.each<[Permission]>([["user:manage"], ["admin"]])("I do not have %s permission", (permission) => { - it("on user CFA", async () => { - const [securityScheme, req] = generateSecuritySchemeFixture(permission, [cfaUser1], location) - await expect( - authorizationMiddleware( - { - method: "get", - path: "/path", - securityScheme, - }, - { - user: { - type: "ICredential", - value: credentialO1, - }, - ...req, - } - ) - ).rejects.toThrow("Forbidden") - }) - }) - - describe.each<[Permission]>([["user:manage"]])("I have %s permission", (permission) => { - it("on user Opco from my Opco", async () => { - const [securityScheme, req] = generateSecuritySchemeFixture(permission, [opcoUserO1U2], location) - await expect( - authorizationMiddleware( - { - method: "get", - path: "/path", - securityScheme, - }, - { - user: { - type: "ICredential", - value: credentialO1, - }, - ...req, - } - ) - ).resolves.toBe(undefined) - }) - }) - - describe.each<[Permission]>([["admin"]])("I do not have %s permission", (permission) => { - it("on user Opco from my Opco", async () => { - const [securityScheme, req] = generateSecuritySchemeFixture(permission, [opcoUserO1U2], location) - await expect( - authorizationMiddleware( - { - method: "get", - path: "/path", - securityScheme, - }, - { - user: { - type: "ICredential", - value: credentialO1, - }, - ...req, - } - ) - ).rejects.toThrow("Forbidden") - }) - }) - }) - - describe("as a cfa user", () => { - describe.each<[Permission]>([["recruiter:manage"], ["recruiter:add_job"]])("I have %s permission", (permission) => { - it("on all my delegated recruiters", async () => { - const [securityScheme, req] = generateSecuritySchemeFixture(permission, [recruteurO1E1R1], location) - await expect( - authorizationMiddleware( - { - method: "get", - path: "/path", - securityScheme, - }, - { - user: { - type: "IUser2", - value: cfaUser1, - }, - ...req, - } - ) - ).resolves.toBe(undefined) - }) - }) - describe.each<[Permission]>([["job:manage"]])("I have %s permission", (permission) => { - it("on all jobs from my delegated recruiters", async () => { - const [securityScheme, req] = generateSecuritySchemeFixture(permission, [...recruteurO1E1R1.jobs], location) - await expect( - authorizationMiddleware( - { - method: "get", - path: "/path", - securityScheme, - }, - { - user: { - type: "IUser2", - value: cfaUser1, - }, - ...req, - } - ) - ).resolves.toBe(undefined) - }) - }) - describe.each<[Permission]>([["user:manage"], ["school:manage"]])("I have %s permission", (permission) => { - it("on myself", async () => { - const [securityScheme, req] = generateSecuritySchemeFixture(permission, [cfaUser1], location) - await expect( - authorizationMiddleware( - { - method: "get", - path: "/path", - securityScheme, - }, - { - user: { - type: "IUser2", - value: cfaUser1, - }, - ...req, - } - ) - ).resolves.toBe(undefined) - }) - }) - - describe.each<[Permission]>([["user:validate"]])("I do not have %s permission", (permission) => { - it("on all my delegated recruiters", async () => { - const [securityScheme, req] = generateSecuritySchemeFixture(permission, [recruteurO1E1R1], location) - await expect( - authorizationMiddleware( - { - method: "get", - path: "/path", - securityScheme, - }, - { - user: { - type: "IUser2", - value: cfaUser1, - }, - ...req, - } - ) - ).rejects.toThrow("Forbidden") - }) - }) - - describe.each<[Permission]>([["recruiter:manage"], ["user:validate"], ["recruiter:add_job"], ["admin"]])("I do not have %s permission", (permission) => { - it("on non delegated recruiters", async () => { - const [securityScheme, req] = generateSecuritySchemeFixture(permission, [recruteurO1E1R2], location) - await expect( - authorizationMiddleware( - { - method: "get", - path: "/path", - securityScheme, - }, - { - user: { - type: "IUser2", - value: cfaUser1, - }, - ...req, - } - ) - ).rejects.toThrow("Forbidden") - }) - }) - describe.each<[Permission]>([["job:manage"], ["admin"]])("I do not have %s permission", (permission) => { - it("on non delegated recruiters", async () => { - const [securityScheme, req] = generateSecuritySchemeFixture(permission, [...recruteurO1E1R2.jobs], location) - await expect( - authorizationMiddleware( - { - method: "get", - path: "/path", - securityScheme, - }, - { - user: { - type: "IUser2", - value: cfaUser1, - }, - ...req, - } - ) - ).rejects.toThrow("Forbidden") - }) - }) - - describe.each<[Permission]>([["school:manage"], ["admin"]])("I do not have %s permission", (permission) => { - it("on other schools", async () => { - const [securityScheme, req] = generateSecuritySchemeFixture(permission, [cfaUser2], location) - await expect( - authorizationMiddleware( - { - method: "get", - path: "/path", - securityScheme, - }, - { - user: { - type: "IUser2", - value: cfaUser1, - }, - ...req, - } - ) - ).rejects.toThrow("Forbidden") - }) - }) - - describe.each<[Permission]>([["application:manage"], ["admin"]])("I do not have %s permission", (permission) => { - it("on application of my delegated recruiter", async () => { - const [securityScheme, req] = generateSecuritySchemeFixture(permission, [applicationO1E1R1J1A1], location) - await expect( - authorizationMiddleware( - { - method: "get", - path: "/path", - securityScheme, - }, - { - user: { - type: "IUser2", - value: cfaUser1, - }, - ...req, - } - ) - ).rejects.toThrow("Forbidden") - }) - }) - - describe.each<[Permission]>([["user:manage"], ["admin"]])("I do not have %s permission", (permission) => { - it("on user recruiter", async () => { - const [securityScheme, req] = generateSecuritySchemeFixture(permission, [recruteurUserO1E1R1], location) - await expect( - authorizationMiddleware( - { - method: "get", - path: "/path", - securityScheme, - }, - { - user: { - type: "IUser2", - value: cfaUser1, - }, - ...req, - } - ) - ).rejects.toThrow("Forbidden") - }) - }) + const givenAnAdminUser = (): AccessUser2 => { + return givenACookieUser(adminUser.user) + } - describe.each<[Permission]>([["user:manage"], ["admin"]])("I do not have %s permission", (permission) => { - it("on admin user", async () => { - const [securityScheme, req] = generateSecuritySchemeFixture(permission, [adminUser], location) - await expect( - authorizationMiddleware( - { - method: "get", - path: "/path", - securityScheme, - }, - { - user: { - type: "IUser2", - value: cfaUser1, - }, - ...req, - } - ) - ).rejects.toThrow("Forbidden") - }) + describe("authorizationMiddleware", () => { + describe.each(everyAuthStrategy)("given a route expecting a %s identity type", (authStrategy) => { + it("should allow to call a route without requested access", async () => { + await expect(authorizationMiddleware(givenARoute({ authStrategy, hasRequestedAccess: false }), emptyRequest)).resolves.toBe(undefined) }) - - describe.each<[Permission]>([["user:manage"], ["admin"]])("I do not have %s permission", (permission) => { - it("on user Opco", async () => { - const [securityScheme, req] = generateSecuritySchemeFixture(permission, [opcoUserO1U1], location) - await expect( - authorizationMiddleware( - { - method: "get", - path: "/path", - securityScheme, - }, - { - user: { - type: "IUser2", - value: cfaUser1, - }, - ...req, - } - ) - ).rejects.toThrow("Forbidden") - }) + it("should reject an unidentified user", async () => { + await expect(authorizationMiddleware(givenARoute({ authStrategy }), emptyRequest)).rejects.toThrow("User should be authenticated") }) }) - - describe("as a recruiter user", () => { - describe.each<[Permission]>([["recruiter:manage"], ["recruiter:add_job"]])("I have %s permission", (permission) => { - it("on me", async () => { - const [securityScheme, req] = generateSecuritySchemeFixture(permission, [recruteurO1E1R1], location) - await expect( - authorizationMiddleware( - { - method: "get", - path: "/path", - securityScheme, - }, - { - user: { - type: "IUser2", - value: recruteurUserO1E1R1, - }, - ...req, - } - ) - ).resolves.toBe(undefined) - }) - }) - describe.each<[Permission]>([["job:manage"]])("I have %s permission", (permission) => { - it("on my jobs", async () => { - const [securityScheme, req] = generateSecuritySchemeFixture(permission, [...recruteurO1E1R1.jobs], location) - await expect( - authorizationMiddleware( - { - method: "get", - path: "/path", - securityScheme, - }, - { - user: { - type: "IUser2", - value: recruteurUserO1E1R1, - }, - ...req, - } - ) - ).resolves.toBe(undefined) - }) - }) - describe.each<[Permission]>([["application:manage"]])("I have %s permission", (permission) => { - it("on my applications", async () => { - const [securityScheme, req] = generateSecuritySchemeFixture(permission, [applicationO1E1R1J1A1, applicationO1E1R1J1A2], location) - await expect( - authorizationMiddleware( - { - method: "get", - path: "/path", - securityScheme, - }, - { - user: { - type: "IUser2", - value: recruteurUserO1E1R1, - }, - ...req, - } - ) - ).resolves.toBe(undefined) - }) - }) - describe.each<[Permission]>([["user:manage"]])("I have %s permission", (permission) => { - it("on myself", async () => { - const [securityScheme, req] = generateSecuritySchemeFixture(permission, [recruteurUserO1E1R1], location) - await expect( - authorizationMiddleware( - { - method: "get", - path: "/path", - securityScheme, - }, - { - user: { - type: "IUser2", - value: recruteurUserO1E1R1, - }, - ...req, - } - ) - ).resolves.toBe(undefined) - }) - }) - describe.each<[Permission]>([["user:validate"]])("I do not have %s permission", (permission) => { - it("on me", async () => { - const [securityScheme, req] = generateSecuritySchemeFixture(permission, [recruteurO1E1R1], location) - await expect( - authorizationMiddleware( - { - method: "get", - path: "/path", - securityScheme, - }, - { - user: { - type: "IUser2", - value: recruteurUserO1E1R1, - }, - ...req, - } - ) - ).rejects.toThrow("Forbidden") - }) - }) - describe.each<[Permission]>([["recruiter:manage"], ["user:validate"], ["recruiter:add_job"], ["admin"]])("I do not have %s permission", (permission) => { - it("on other recruiters from my company", async () => { - const [securityScheme, req] = generateSecuritySchemeFixture(permission, [recruteurO1E1R2], location) - await expect( - authorizationMiddleware( - { - method: "get", - path: "/path", - securityScheme, - }, - { - user: { - type: "IUser2", - value: recruteurUserO1E1R1, - }, - ...req, - } - ) - ).rejects.toThrow("Forbidden") - }) - }) - describe.each<[Permission]>([["job:manage"], ["admin"]])("I do not have %s permission", (permission) => { - it("on job from other recruiters from my company", async () => { - const [securityScheme, req] = generateSecuritySchemeFixture(permission, [...recruteurO1E1R2.jobs], location) - await expect( - authorizationMiddleware( - { - method: "get", - path: "/path", - securityScheme, - }, - { - user: { - type: "IUser2", - value: recruteurUserO1E1R1, - }, - ...req, - } - ) - ).rejects.toThrow("Forbidden") - }) - }) - describe.each<[Permission]>([["application:manage"], ["admin"]])("I do not have %s permission", (permission) => { - it("on other applications", async () => { - const [securityScheme, req] = generateSecuritySchemeFixture(permission, [applicationO1E1R2J1A1], location) - await expect( - authorizationMiddleware( - { - method: "get", - path: "/path", - securityScheme, - }, - { - user: { - type: "IUser2", - value: recruteurUserO1E1R1, - }, - ...req, - } - ) - ).rejects.toThrow("Forbidden") - }) - }) - describe.each<[Permission]>([["user:manage"], ["school:manage"], ["admin"]])("I do not have %s permission", (permission) => { - it("on school", async () => { - const [securityScheme, req] = generateSecuritySchemeFixture(permission, [cfaUser1], location) - await expect( - authorizationMiddleware( - { - method: "get", - path: "/path", - securityScheme, - }, - { - user: { - type: "IUser2", - value: recruteurUserO1E1R1, - }, - ...req, - } - ) - ).rejects.toThrow("Forbidden") - }) - }) - describe.each<[Permission]>([["user:manage"]])("I do not have %s permission", (permission) => { - it("on other user recruiter", async () => { - const [securityScheme, req] = generateSecuritySchemeFixture(permission, [recruteurUserO1E1R2], location) - await expect( - authorizationMiddleware( - { - method: "get", - path: "/path", - securityScheme, - }, - { - user: { - type: "IUser2", - value: recruteurUserO1E1R1, - }, - ...req, - } - ) - ).rejects.toThrow("Forbidden") - }) - }) - describe.each<[Permission]>([["user:manage"], ["admin"]])("I do not have %s permission", (permission) => { - it("on admin user", async () => { - const [securityScheme, req] = generateSecuritySchemeFixture(permission, [adminUser], location) - await expect( - authorizationMiddleware( - { - method: "get", - path: "/path", - securityScheme, - }, - { - user: { - type: "IUser2", - value: recruteurUserO1E1R1, - }, - ...req, - } - ) - ).rejects.toThrow("Forbidden") - }) + describe.each(everyResourceType)("given an accessed resource of type %s", (resourceType) => { + it("should always allow a token user", async () => { + await expect( + authorizationMiddleware(givenARoute({ authStrategy: "access-token", resourceType }), givenARequest({ user: givenATokenUser(), resourceId: "resourceId" })) + ).resolves.toBe(undefined) }) - describe.each<[Permission]>([["user:manage"], ["admin"]])("I do not have %s permission", (permission) => { - it("on user Opco", async () => { - const [securityScheme, req] = generateSecuritySchemeFixture(permission, [opcoUserO1U1], location) - await expect( - authorizationMiddleware( - { - method: "get", - path: "/path", - securityScheme, - }, - { - user: { - type: "IUser2", - value: recruteurUserO1E1R1, - }, - ...req, - } - ) - ).rejects.toThrow("Forbidden") - }) + it("should always allow an admin user", async () => { + await expect( + authorizationMiddleware(givenARoute({ authStrategy: "cookie-session", resourceType }), givenARequest({ user: givenAnAdminUser(), resourceId: "resourceId" })) + ).resolves.toBe(undefined) }) }) - describe("as a pending recruiter user", () => { - describe.each<[Permission]>([["recruiter:add_job"]])("I have %s permission", (permission) => { - it("on me", async () => { - const [securityScheme, req] = generateSecuritySchemeFixture(permission, [recruteurUserO2E1R1P], location) - await expect( - authorizationMiddleware( - { - method: "get", - path: "/path", - securityScheme, - }, - { - user: { - type: "IUser2", - value: recruteurUserO2E1R1P, - }, - ...req, - } - ) - ).resolves.toBe(undefined) - }) - }) - describe.each<[Permission]>([["user:validate"]])("I do not have %s permission", (permission) => { - it("on me", async () => { - const [securityScheme, req] = generateSecuritySchemeFixture(permission, [recruteurO2E1R1P], location) - await expect( - authorizationMiddleware( - { - method: "get", - path: "/path", - securityScheme, - }, - { - user: { - type: "IUser2", - value: recruteurUserO2E1R1P, - }, - ...req, - } - ) - ).rejects.toThrow("Forbidden") - }) - }) - describe.each<[Permission]>([["recruiter:manage"], ["user:validate"], ["recruiter:add_job"], ["admin"]])("I do not have %s permission", (permission) => { - it("on other recruiters from my company", async () => { - const [securityScheme, req] = generateSecuritySchemeFixture(permission, [recruteurO1E1R2], location) - await expect( - authorizationMiddleware( - { - method: "get", - path: "/path", - securityScheme, - }, - { - user: { - type: "IUser2", - value: recruteurUserO2E1R1P, - }, - ...req, - } - ) - ).rejects.toThrow("Forbidden") - }) - }) - describe.each<[Permission]>([["job:manage"], ["admin"]])("I do not have %s permission", (permission) => { - it("on job from other recruiters from my company", async () => { - const [securityScheme, req] = generateSecuritySchemeFixture(permission, [...recruteurO1E1R2.jobs], location) - await expect( - authorizationMiddleware( - { - method: "get", - path: "/path", - securityScheme, - }, - { - user: { - type: "IUser2", - value: recruteurUserO2E1R1P, - }, - ...req, - } - ) - ).rejects.toThrow("Forbidden") - }) - }) - describe.each<[Permission]>([["application:manage"], ["admin"]])("I do not have %s permission", (permission) => { - it("on other applications", async () => { - const [securityScheme, req] = generateSecuritySchemeFixture(permission, [applicationO1E1R2J1A1], location) - await expect( - authorizationMiddleware( - { - method: "get", - path: "/path", - securityScheme, - }, - { - user: { - type: "IUser2", - value: recruteurUserO2E1R1P, - }, - ...req, - } - ) - ).rejects.toThrow("Forbidden") - }) - }) - describe.each<[Permission]>([["user:manage"], ["school:manage"], ["admin"]])("I do not have %s permission", (permission) => { - it("on school", async () => { - const [securityScheme, req] = generateSecuritySchemeFixture(permission, [cfaUser1], location) - await expect( - authorizationMiddleware( - { - method: "get", - path: "/path", - securityScheme, - }, - { - user: { - type: "IUser2", - value: recruteurUserO2E1R1P, - }, - ...req, - } - ) - ).rejects.toThrow("Forbidden") - }) - }) - describe.each<[Permission]>([["user:manage"]])("I do not have %s permission", (permission) => { - it("on other user recruiter", async () => { - const [securityScheme, req] = generateSecuritySchemeFixture(permission, [recruteurUserO1E1R2], location) - await expect( - authorizationMiddleware( - { - method: "get", - path: "/path", - securityScheme, - }, - { - user: { - type: "IUser2", - value: recruteurUserO2E1R1P, - }, - ...req, - } - ) - ).rejects.toThrow("Forbidden") - }) - }) - describe.each<[Permission]>([["user:manage"], ["admin"]])("I do not have %s permission", (permission) => { - it("on admin user", async () => { - const [securityScheme, req] = generateSecuritySchemeFixture(permission, [adminUser], location) - await expect( - authorizationMiddleware( - { - method: "get", - path: "/path", - securityScheme, - }, - { - user: { - type: "IUser2", - value: recruteurUserO2E1R1P, - }, - ...req, - } - ) - ).rejects.toThrow("Forbidden") - }) + describe("user access", () => { + it("an entreprise user should have access to its user", async () => { + const user = entrepriseUserA.user + await expect( + authorizationMiddleware( + givenARoute({ authStrategy: "cookie-session", resourceType: "user" }), + givenARequest({ user: givenACookieUser(user), resourceId: user._id.toString() }) + ) + ).resolves.toBe(undefined) }) - describe.each<[Permission]>([["user:manage"], ["admin"]])("I do not have %s permission", (permission) => { - it("on user Opco", async () => { - const [securityScheme, req] = generateSecuritySchemeFixture(permission, [opcoUserO1U1], location) - await expect( - authorizationMiddleware( - { - method: "get", - path: "/path", - securityScheme, - }, - { - user: { - type: "IUser2", - value: recruteurUserO2E1R1P, - }, - ...req, - } - ) - ).rejects.toThrow("Forbidden") - }) + it("an entreprise user should NOT have access to another user", async () => { + const user = entrepriseUserA.user + const accessedUser = cfaUserA.user + await expect( + authorizationMiddleware( + givenARoute({ authStrategy: "cookie-session", resourceType: "user" }), + givenARequest({ user: givenACookieUser(user), resourceId: accessedUser._id.toString() }) + ) + ).rejects.toThrow("non autorisé") }) - }) - }) - - it("should support retrieving recruiter resource per establishment_id", async () => { - const securityScheme: SecurityScheme = { - auth: "cookie-session", - access: "recruiter:manage", - resources: { - recruiter: [ - { - establishment_id: { - type: "query", - key: "establishment_id", - }, - }, - ], - }, - } - - const query = { - establishment_id: recruteurO1E1R1.establishment_id, - } - - await expect( - authorizationMiddleware( - { - method: "get", - path: "/path", - securityScheme, - }, - { - user: { - type: "IUser2", - value: recruteurUserO1E1R1, - }, - query, - params: {}, - } - ) - ).resolves.toBe(undefined) - await expect( - authorizationMiddleware( - { - method: "get", - path: "/path", - securityScheme, - }, - { - user: { - type: "IUser2", - value: recruteurUserO1E1R2, - }, - query, - params: {}, - } - ) - ).rejects.toThrow("Forbidden") - }) - - it("should support some operator permission", async () => { - const securityScheme: SecurityScheme = { - auth: "cookie-session", - access: { some: ["recruiter:manage", "user:validate"] }, - resources: { - recruiter: [ - { - establishment_id: { - type: "query", - key: "establishment_id", - }, - }, - ], - }, - } - - const query = { - establishment_id: recruteurO1E1R1.establishment_id, - } - - await expect( - authorizationMiddleware( - { - method: "get", - path: "/path", - securityScheme, - }, - { - user: { - type: "IUser2", - value: recruteurUserO1E1R1, - }, - query, - params: {}, - } - ) - ).resolves.toBe(undefined) - await expect( - authorizationMiddleware( - { - method: "get", - path: "/path", - securityScheme, - }, - { - user: { - type: "IUser2", - value: recruteurUserO1E1R2, - }, - query, - params: {}, - } - ) - ).rejects.toThrow("Forbidden") - }) - - it("should support every operator permission", async () => { - const securityScheme: SecurityScheme = { - auth: "cookie-session", - access: { every: ["recruiter:manage", "user:validate"] }, - resources: { - recruiter: [ - { - establishment_id: { - type: "query", - key: "establishment_id", - }, - }, - ], - }, - } - - const query = { - establishment_id: recruteurO1E1R1.establishment_id, - } - - await expect( - authorizationMiddleware( - { - method: "get", - path: "/path", - securityScheme, - }, - { - user: { - type: "IUser2", - value: opcoUserO1U1, - }, - query, - params: {}, - } - ) - ).resolves.toBe(undefined) - await expect( - authorizationMiddleware( - { - method: "get", - path: "/path", - securityScheme, - }, - { - user: { - type: "IUser2", - value: recruteurUserO1E1R1, - }, - query, - params: {}, - } - ) - ).rejects.toThrow("Forbidden") - }) - - it("should support null access", async () => { - const securityScheme: SecurityScheme = { - auth: "cookie-session", - access: null, - resources: {}, - } - - await expect( - authorizationMiddleware( - { - method: "get", - path: "/path", - securityScheme, - }, - { - user: { - type: "IUser2", - value: opcoUserO1U1, - }, - query: {}, - params: {}, - } - ) - ).resolves.toBe(undefined) - }) - - describe("with access token", () => { - describe("when accessing recruiter resource", () => { - it("should allow when resource is present in token for same scope", async () => { - const securityScheme: SecurityScheme = { - auth: "cookie-session", - access: "recruiter:manage", - resources: { - recruiter: [{ _id: { type: "params", key: "id" } }], - }, - } - const userWithType: AccessUserToken = { - type: "IAccessToken", - value: { - identity: { type: "cfa", email: "mail@mail.com", siret: "55327987900672" }, - scopes: [ - generateScope({ - schema: { - method: "post", - path: "/path/:id", - securityScheme: { - auth: "access-token", - access: null, - resources: {}, - }, - }, - options: "all", - }), - generateScope({ - schema: { - method: "get", - path: "/path/:id", - securityScheme: { - auth: "access-token", - access: null, - resources: {}, - }, - }, - options: "all", - }), - ], - }, - } - + it("a cfa user should have access to its user", async () => { + const user = cfaUserA.user await expect( authorizationMiddleware( - { - method: "get", - path: "/path/:id", - securityScheme, - }, - { - user: userWithType, - query: {}, - params: { - id: recruteurO1E1R1._id.toString(), - }, - } + givenARoute({ authStrategy: "cookie-session", resourceType: "user" }), + givenARequest({ user: givenACookieUser(user), resourceId: user._id.toString() }) ) ).resolves.toBe(undefined) }) - }) - describe("when accessing job resource", () => { - it("should allow when resource is present in token for same scope", async () => { - const securityScheme: SecurityScheme = { - auth: "cookie-session", - access: "job:manage", - resources: { - job: [{ _id: { type: "params", key: "id" } }], - }, - } - const userWithType: AccessUserToken = { - type: "IAccessToken", - value: { - identity: { type: "cfa", email: "mail@mail.com", siret: "55327987900672" }, - scopes: [ - generateScope({ - schema: { - method: "post", - path: "/path/:id", - securityScheme: { - auth: "access-token", - access: null, - resources: {}, - }, - }, - options: "all", - }), - generateScope({ - schema: { - method: "get", - path: "/path/:id", - securityScheme: { - auth: "access-token", - access: null, - resources: {}, - }, - }, - options: "all", - }), - ], - }, - } - + it("an opco user should have access to its user", async () => { + const user = opcoUserA.user await expect( authorizationMiddleware( - { - method: "get", - path: "/path/:id", - securityScheme, - }, - { - user: userWithType, - query: {}, - params: { - id: recruteurO1E1R1.jobs.map((j) => j._id.toString())[0], - }, - } + givenARoute({ authStrategy: "cookie-session", resourceType: "user" }), + givenARequest({ user: givenACookieUser(user), resourceId: user._id.toString() }) ) ).resolves.toBe(undefined) }) }) - describe("when accessing application resource", () => { - it("should allow when resource is present in token for same scope", async () => { - const securityScheme: SecurityScheme = { - auth: "cookie-session", - access: "application:manage", - resources: { - application: [{ _id: { type: "params", key: "id" } }], - }, - } - const userWithType: AccessUserToken = { - type: "IAccessToken", - value: { - identity: { type: "cfa", email: "mail@mail.com", siret: "55327987900672" }, - scopes: [ - generateScope({ - schema: { - method: "post", - path: "/path/:id", - securityScheme: { - auth: "access-token", - access: null, - resources: {}, - }, - }, - options: "all", - }), - generateScope({ - schema: { - method: "get", - path: "/path/:id", - securityScheme: { - auth: "access-token", - access: null, - resources: {}, - }, - }, - options: "all", - }), - ], - }, - } - + describe("job access", () => { + it("an entreprise user should have access to its jobs", async () => { + const { user, recruiter } = entrepriseUserA + const [job] = recruiter.jobs await expect( authorizationMiddleware( - { - method: "get", - path: "/path/:id", - securityScheme, - }, - { - user: userWithType, - query: {}, - params: { - id: applicationO1E1R1J1A1._id.toString(), - }, - } + givenARoute({ authStrategy: "cookie-session", resourceType: "job" }), + givenARequest({ user: givenACookieUser(user), resourceId: job._id.toString() }) ) ).resolves.toBe(undefined) }) - }) - describe("when accessing user resource", () => { - it("should allow when resource is present in token for same scope", async () => { - const securityScheme: SecurityScheme = { - auth: "cookie-session", - access: "user:manage", - resources: { - user: [{ _id: { type: "params", key: "id" } }], - }, - } - const userWithType: AccessUserToken = { - type: "IAccessToken", - value: { - identity: { type: "cfa", email: "mail@mail.com", siret: "55327987900672" }, - scopes: [ - generateScope({ - schema: { - method: "post", - path: "/path/:id", - securityScheme: { - auth: "access-token", - access: null, - resources: {}, - }, - }, - options: "all", - }), - generateScope({ - schema: { - method: "get", - path: "/path/:id", - securityScheme: { - auth: "access-token", - access: null, - resources: {}, - }, - }, - options: "all", - }), - ], - }, - } - + it("an entreprise user should NOT have access to another entreprise jobs", async () => { + const user = entrepriseUserA.user + const { recruiter } = entrepriseUserB + const [job] = recruiter.jobs await expect( authorizationMiddleware( - { - method: "get", - path: "/path/:id", - securityScheme, - }, - { - user: userWithType, - query: {}, - params: { - id: opcoUserO1U1._id.toString(), - }, - } + givenARoute({ authStrategy: "cookie-session", resourceType: "job" }), + givenARequest({ user: givenACookieUser(user), resourceId: job._id.toString() }) + ) + ).rejects.toThrow("non autorisé") + }) + it("a cfa user should have access to its jobs", async () => { + const { user, recruiter } = cfaUserA + const [job] = recruiter.jobs + await expect( + authorizationMiddleware( + givenARoute({ authStrategy: "cookie-session", resourceType: "job" }), + givenARequest({ user: givenACookieUser(user), resourceId: job._id.toString() }) ) ).resolves.toBe(undefined) }) + it("a cfa user should NOT have access to another cfa job", async () => { + const user = cfaUserA.user + const { recruiter } = cfaUserB + const [job] = recruiter.jobs + await expect( + authorizationMiddleware( + givenARoute({ authStrategy: "cookie-session", resourceType: "job" }), + givenARequest({ user: givenACookieUser(user), resourceId: job._id.toString() }) + ) + ).rejects.toThrow("non autorisé") + }) + it("a cfa user should NOT have access to another entreprise job", async () => { + const user = cfaUserA.user + const { recruiter } = entrepriseUserA + const [job] = recruiter.jobs + await expect( + authorizationMiddleware( + givenARoute({ authStrategy: "cookie-session", resourceType: "job" }), + givenARequest({ user: givenACookieUser(user), resourceId: job._id.toString() }) + ) + ).rejects.toThrow("non autorisé") + }) }) }) }) diff --git a/server/tests/utils/user.utils.ts b/server/tests/utils/user.utils.ts index b553d9bbbb..56f89f9909 100644 --- a/server/tests/utils/user.utils.ts +++ b/server/tests/utils/user.utils.ts @@ -1,11 +1,15 @@ -import { ETAT_UTILISATEUR } from "shared/constants/recruteur" +import { ETAT_UTILISATEUR, RECRUITER_STATUS } from "shared/constants/recruteur" import { extensions } from "shared/helpers/zodHelpers/zodPrimitives" -import { IApplication, ICredential, IEmailBlacklist, IJob, IRecruiter, IUserRecruteur, ZApplication, ZCredential, ZEmailBlacklist, ZRecruiter, ZUserRecruteur } from "shared/models" +import { IApplication, ICredential, IEmailBlacklist, IJob, IRecruiter, IUserRecruteur, JOB_STATUS, ZApplication, ZCredential, ZEmailBlacklist, ZUserRecruteur } from "shared/models" +import { ICFA, zCFA } from "shared/models/cfa.model" import { zObjectId } from "shared/models/common" -import { ZodObject, ZodString } from "zod" +import { IEntreprise, ZEntreprise } from "shared/models/entreprise.model" +import { AccessEntityType, IRoleManagement } from "shared/models/roleManagement.model" +import { IUser2, ZUser2 } from "shared/models/user2.model" +import { ZodObject, ZodString, ZodTypeAny } from "zod" import { Fixture, Generator } from "zod-fixture" -import { Application, Credential, EmailBlacklist, Recruiter, UserRecruteur } from "@/common/model" +import { Application, Cfa, Credential, EmailBlacklist, Entreprise, Recruiter, RoleManagement, User2, UserRecruteur } from "@/common/model" import { ObjectId } from "@/common/mongodb" let seed = 0 @@ -48,6 +52,77 @@ function getFixture() { ]) } +export const saveDbEntity = async (schema: ZodTypeAny, dbModel: (item: T) => { save: () => Promise } & T, data: Partial) => { + const u = dbModel({ + ...getFixture().fromSchema(schema), + ...data, + }) + await u.save() + return u +} + +export const saveUser2 = async (data: Partial = {}) => { + return saveDbEntity(ZUser2, (item) => new User2(item), data) +} +export const saveRoleManagement = async (data: Partial = {}) => { + const role: IRoleManagement = { + _id: new ObjectId(), + authorized_id: "id", + authorized_type: AccessEntityType.CFA, + createdAt: new Date(), + origin: "origin", + status: [], + updatedAt: new Date(), + user_id: new ObjectId(), + ...data, + } + await new RoleManagement(role).save() + return role +} +export const saveEntreprise = async (data: Partial = {}) => { + return saveDbEntity(ZEntreprise, (item) => new Entreprise(item), data) +} +export const saveCfa = async (data: Partial = {}) => { + return saveDbEntity(zCFA, (item) => new Cfa(item), data) +} + +export const jobFactory = (props: Partial = {}) => { + const job: IJob = { + _id: new ObjectId(), + rome_label: "rome_label", + rome_appellation_label: "rome_appellation_label", + job_level_label: "job_level_label", + job_start_date: new Date(), + job_description: "job_description", + job_employer_description: "job_employer_description", + rome_code: ["rome_code"], + rome_detail: null, + job_creation_date: new Date(), + job_expiration_date: new Date(), + job_update_date: new Date(), + job_last_prolongation_date: new Date(), + job_prolongation_count: 0, + relance_mail_sent: false, + job_status: JOB_STATUS.ACTIVE, + job_status_comment: "job_status_comment", + job_type: ["Apprentissage"], + is_multi_published: false, + job_delegation_count: 0, + delegations: [], + is_disabled_elligible: false, + job_count: 1, + job_duration: 6, + job_rythm: "job_rythm", + custom_address: "custom_address", + custom_geo_coordinates: "custom_geo_coordinates", + stats_detail_view: 0, + stats_search_view: 0, + managed_by: new ObjectId(), + ...props, + } + return job +} + export async function createUserRecruteurTest(data: Partial, userState: string = ETAT_UTILISATEUR.VALIDE) { const u = new UserRecruteur({ ...getFixture().fromSchema(ZUserRecruteur), @@ -67,17 +142,41 @@ export async function createCredentialTest(data: Partial) { return u } -export async function createRecruteurTest(data: Partial, jobsData: Partial[]) { - const u = new Recruiter({ - ...getFixture().fromSchema(ZRecruiter), +export async function saveRecruiter(data: Partial) { + const recruiter: IRecruiter = { + _id: new ObjectId(), + distance: 10, + createdAt: new Date(), + updatedAt: new Date(), + establishment_id: "establishment_id", + establishment_raison_sociale: "establishment_raison_sociale", + establishment_enseigne: "establishment_enseigne", + establishment_siret: "establishment_siret", + address_detail: "address_detail", + address: "address", + geo_coordinates: "geo_coordinates", + geopoint: { + type: "Point", + coordinates: [41, 10], + }, + is_delegated: false, + cfa_delegated_siret: "cfa_delegated_siret", + last_name: "last_name", + first_name: "first_name", + phone: "phone", + email: "email", + jobs: [], + origin: "origin", + opco: "opco", + idcc: "idcc", + status: RECRUITER_STATUS.ACTIF, + naf_code: "naf_code", + naf_label: "naf_label", + establishment_size: "establishment_size", + establishment_creation_date: new Date(), ...data, - jobs: jobsData.map((d) => { - return { - ...getFixture().fromSchema(ZRecruiter), - ...d, - } - }), - }) + } + const u = new Recruiter(recruiter) await u.save() return u } diff --git a/shared/helpers/openapi/__snapshots__/generateOpenapi.test.ts.snap b/shared/helpers/openapi/__snapshots__/generateOpenapi.test.ts.snap index 0ff1ba2940..7203f60d2c 100644 --- a/shared/helpers/openapi/__snapshots__/generateOpenapi.test.ts.snap +++ b/shared/helpers/openapi/__snapshots__/generateOpenapi.test.ts.snap @@ -867,6 +867,9 @@ exports[`generateOpenApiSchema > should generate proper schema 1`] = ` "null", ], }, + "managed_by": { + "description": "Id de l'utilisateur gérant l'offre", + }, "relance_mail_sent": { "description": "Statut de l'envoi du mail de relance avant expiration", "type": [ From 4f61e4feb45ea09d894cad9189fb1f31b469c79e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9my=20Auricoste?= Date: Wed, 27 Mar 2024 14:32:46 +0100 Subject: [PATCH 61/78] fix: tests create user and log --- .../security/authorisationService.test.ts | 113 ++---------------- server/tests/utils/login.utils.ts | 22 +++- server/tests/utils/user.utils.ts | 89 +++++++++++++- shared/routes/recruiters.routes.ts | 2 +- 4 files changed, 117 insertions(+), 109 deletions(-) diff --git a/server/tests/unit/security/authorisationService.test.ts b/server/tests/unit/security/authorisationService.test.ts index 18e0806639..d6ce3acd40 100644 --- a/server/tests/unit/security/authorisationService.test.ts +++ b/server/tests/unit/security/authorisationService.test.ts @@ -1,6 +1,4 @@ import { FastifyRequest } from "fastify" -import { OPCOS, RECRUITER_STATUS, VALIDATION_UTILISATEUR } from "shared/constants/recruteur" -import { AccessEntityType, AccessStatus, IRoleManagementEvent } from "shared/models/roleManagement.model" import { IUser2 } from "shared/models/user2.model" import { AuthStrategy, IRouteSchema, WithSecurityScheme } from "shared/routes/common.routes" import { AccessRessouces } from "shared/security/permissions" @@ -9,8 +7,7 @@ import { describe, expect, it } from "vitest" import { AccessUser2, AccessUserCredential, AccessUserToken } from "@/security/authenticationService" import { authorizationMiddleware } from "@/security/authorisationService" import { useMongo } from "@tests/utils/mongo.utils" - -import { jobFactory, saveCfa, saveEntreprise, saveRecruiter, saveRoleManagement, saveUser2 } from "../../utils/user.utils" +import { saveAdminUserTest, saveCfaUserTest, saveEntrepriseUserTest, saveOpcoUserTest } from "@tests/utils/user.utils" type MockedRequest = Pick const emptyRequest: MockedRequest = { params: {}, query: {} } @@ -45,90 +42,6 @@ const givenARoute = ({ const everyResourceType: ResourceType[] = ["application", "appointment", "formationCatalogue", "job", "recruiter", "user"] const everyAuthStrategy: AuthStrategy[] = ["access-token", "api-key", "cookie-session"] -const roleManagementEventFactory = ({ - date = new Date(), - granted_by, - reason = "reason", - status = AccessStatus.GRANTED, - validation_type = VALIDATION_UTILISATEUR.AUTO, -}: Partial = {}): IRoleManagementEvent => { - return { - date, - granted_by, - reason, - status, - validation_type, - } -} - -const createAdminUser = async () => { - const user = await saveUser2() - const role = await saveRoleManagement({ - user_id: user._id, - authorized_type: AccessEntityType.ADMIN, - authorized_id: undefined, - status: [roleManagementEventFactory()], - }) - return { user, role } -} - -const createEntrepriseUser = async () => { - const user = await saveUser2() - const entreprise = await saveEntreprise() - const role = await saveRoleManagement({ - user_id: user._id, - authorized_id: entreprise._id.toString(), - authorized_type: AccessEntityType.ENTREPRISE, - status: [roleManagementEventFactory()], - }) - const recruiter = await saveRecruiter({ - is_delegated: false, - cfa_delegated_siret: null, - status: RECRUITER_STATUS.ACTIF, - establishment_siret: entreprise.siret, - opco: entreprise.opco, - jobs: [ - jobFactory({ - managed_by: user._id, - }), - ], - }) - return { user, role, entreprise, recruiter } -} - -const createCfaUser = async () => { - const user = await saveUser2() - const cfa = await saveCfa() - const role = await saveRoleManagement({ - user_id: user._id, - authorized_id: cfa._id.toString(), - authorized_type: AccessEntityType.CFA, - status: [roleManagementEventFactory()], - }) - const recruiter = await saveRecruiter({ - is_delegated: true, - cfa_delegated_siret: cfa.siret, - status: RECRUITER_STATUS.ACTIF, - jobs: [ - jobFactory({ - managed_by: user._id, - }), - ], - }) - return { user, role, cfa, recruiter } -} - -const createOpcoUser = async () => { - const user = await saveUser2() - const role = await saveRoleManagement({ - user_id: user._id, - authorized_id: OPCOS.AKTO, - authorized_type: AccessEntityType.OPCO, - status: [roleManagementEventFactory()], - }) - return { user, role } -} - const givenATokenUser = (): AccessUserToken => { return { type: "IAccessToken", @@ -158,20 +71,20 @@ const givenARequest = ({ user, resourceId }: { user: AccessUserToken | AccessUse } describe("authorisationService", async () => { - let adminUser: Awaited> - let entrepriseUserA: Awaited> - let entrepriseUserB: Awaited> - let cfaUserA: Awaited> - let cfaUserB: Awaited> - let opcoUserA: Awaited> + let adminUser: Awaited> + let entrepriseUserA: Awaited> + let entrepriseUserB: Awaited> + let cfaUserA: Awaited> + let cfaUserB: Awaited> + let opcoUserA: Awaited> useMongo(async () => { - adminUser = await createAdminUser() - entrepriseUserA = await createEntrepriseUser() - entrepriseUserB = await createEntrepriseUser() - cfaUserA = await createCfaUser() - cfaUserB = await createCfaUser() - opcoUserA = await createOpcoUser() + adminUser = await saveAdminUserTest() + entrepriseUserA = await saveEntrepriseUserTest() + entrepriseUserB = await saveEntrepriseUserTest() + cfaUserA = await saveCfaUserTest() + cfaUserB = await saveCfaUserTest() + opcoUserA = await saveOpcoUserTest() }, "beforeAll") const givenACookieUser = (user: IUser2): AccessUser2 => { diff --git a/server/tests/utils/login.utils.ts b/server/tests/utils/login.utils.ts index 118e20f410..82208eb661 100644 --- a/server/tests/utils/login.utils.ts +++ b/server/tests/utils/login.utils.ts @@ -1,18 +1,28 @@ -import { ETAT_UTILISATEUR } from "shared/constants/recruteur" -import { IUserRecruteur } from "shared/models" +import { IUser2 } from "shared/models/user2.model" -import { UserRecruteur } from "@/common/model" import { Server } from "@/http/server" +import { user2ToUserForToken } from "@/security/accessTokenService" import { createAuthMagicLinkToken } from "@/services/appLinks.service" -export const createAndLogUser = async (httpClient: () => Server, username: string, options: Partial = {}) => { +import { saveAdminUserTest, saveCfaUserTest } from "./user.utils" + +export const createAndLogUser = async (httpClient: () => Server, username: string, { type }: { type: "CFA" | "ADMIN" }) => { const email = `${username.toLowerCase()}@mail.com` - const user = await UserRecruteur.create({ username, email, first_name: "first name", last_name: "last name", status: [{ status: ETAT_UTILISATEUR.VALIDE }], ...options }) + let user: IUser2 + if (type === "ADMIN") { + const result = await saveAdminUserTest({ email }) + user = result.user + } else if (type === "CFA") { + const result = await saveCfaUserTest({ email }) + user = result.user + } else { + throw new Error(`Unsupported type ${type}`) + } const response = await httpClient().inject({ method: "POST", path: "/api/login/verification", - headers: { authorization: `Bearer ${createAuthMagicLinkToken(user)}` }, + headers: { authorization: `Bearer ${createAuthMagicLinkToken(user2ToUserForToken(user))}` }, }) return { Cookie: response.cookies.reduce((acc, cookie) => `${acc} ${cookie.name}=${cookie.value}`, ""), diff --git a/server/tests/utils/user.utils.ts b/server/tests/utils/user.utils.ts index 56f89f9909..a50cc907c7 100644 --- a/server/tests/utils/user.utils.ts +++ b/server/tests/utils/user.utils.ts @@ -1,10 +1,10 @@ -import { ETAT_UTILISATEUR, RECRUITER_STATUS } from "shared/constants/recruteur" +import { ETAT_UTILISATEUR, OPCOS, RECRUITER_STATUS, VALIDATION_UTILISATEUR } from "shared/constants/recruteur" import { extensions } from "shared/helpers/zodHelpers/zodPrimitives" import { IApplication, ICredential, IEmailBlacklist, IJob, IRecruiter, IUserRecruteur, JOB_STATUS, ZApplication, ZCredential, ZEmailBlacklist, ZUserRecruteur } from "shared/models" import { ICFA, zCFA } from "shared/models/cfa.model" import { zObjectId } from "shared/models/common" import { IEntreprise, ZEntreprise } from "shared/models/entreprise.model" -import { AccessEntityType, IRoleManagement } from "shared/models/roleManagement.model" +import { AccessEntityType, AccessStatus, IRoleManagement, IRoleManagementEvent } from "shared/models/roleManagement.model" import { IUser2, ZUser2 } from "shared/models/user2.model" import { ZodObject, ZodString, ZodTypeAny } from "zod" import { Fixture, Generator } from "zod-fixture" @@ -79,6 +79,23 @@ export const saveRoleManagement = async (data: Partial = {}) => await new RoleManagement(role).save() return role } + +export const roleManagementEventFactory = ({ + date = new Date(), + granted_by, + reason = "reason", + status = AccessStatus.GRANTED, + validation_type = VALIDATION_UTILISATEUR.AUTO, +}: Partial = {}): IRoleManagementEvent => { + return { + date, + granted_by, + reason, + status, + validation_type, + } +} + export const saveEntreprise = async (data: Partial = {}) => { return saveDbEntity(ZEntreprise, (item) => new Entreprise(item), data) } @@ -198,3 +215,71 @@ export async function createEmailBlacklistTest(data: Partial) { await u.save() return u } + +export const saveAdminUserTest = async (userProps: Partial = {}) => { + const user = await saveUser2(userProps) + const role = await saveRoleManagement({ + user_id: user._id, + authorized_type: AccessEntityType.ADMIN, + authorized_id: undefined, + status: [roleManagementEventFactory()], + }) + return { user, role } +} + +export const saveEntrepriseUserTest = async () => { + const user = await saveUser2() + const entreprise = await saveEntreprise() + const role = await saveRoleManagement({ + user_id: user._id, + authorized_id: entreprise._id.toString(), + authorized_type: AccessEntityType.ENTREPRISE, + status: [roleManagementEventFactory()], + }) + const recruiter = await saveRecruiter({ + is_delegated: false, + cfa_delegated_siret: null, + status: RECRUITER_STATUS.ACTIF, + establishment_siret: entreprise.siret, + opco: entreprise.opco, + jobs: [ + jobFactory({ + managed_by: user._id, + }), + ], + }) + return { user, role, entreprise, recruiter } +} + +export const saveCfaUserTest = async (userProps: Partial = {}) => { + const user = await saveUser2(userProps) + const cfa = await saveCfa() + const role = await saveRoleManagement({ + user_id: user._id, + authorized_id: cfa._id.toString(), + authorized_type: AccessEntityType.CFA, + status: [roleManagementEventFactory()], + }) + const recruiter = await saveRecruiter({ + is_delegated: true, + cfa_delegated_siret: cfa.siret, + status: RECRUITER_STATUS.ACTIF, + jobs: [ + jobFactory({ + managed_by: user._id, + }), + ], + }) + return { user, role, cfa, recruiter } +} + +export const saveOpcoUserTest = async () => { + const user = await saveUser2() + const role = await saveRoleManagement({ + user_id: user._id, + authorized_id: OPCOS.AKTO, + authorized_type: AccessEntityType.OPCO, + status: [roleManagementEventFactory()], + }) + return { user, role } +} diff --git a/shared/routes/recruiters.routes.ts b/shared/routes/recruiters.routes.ts index 35bcbaaebd..5bd0ca15ec 100644 --- a/shared/routes/recruiters.routes.ts +++ b/shared/routes/recruiters.routes.ts @@ -116,7 +116,7 @@ export const zRecruiterRoutes = { auth: "cookie-session", access: "user:manage", resources: { - user: [{ _id: { type: "params", key: "userRecruteurId" } }], + user: [{ _id: { type: "params", key: "cfaId" } }], }, }, }, From 75e5ea768978835e6905123f2b30c846e6d994e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9my=20Auricoste?= Date: Wed, 27 Mar 2024 15:08:55 +0100 Subject: [PATCH 62/78] fix: accessTokenService.test --- server/src/jobs/database/validateModels.ts | 3 -- .../unit/security/accessTokenService.test.ts | 47 ++++++++++++++----- server/tests/utils/user.utils.ts | 36 +++++++------- 3 files changed, 55 insertions(+), 31 deletions(-) diff --git a/server/src/jobs/database/validateModels.ts b/server/src/jobs/database/validateModels.ts index f72768356e..1325511648 100644 --- a/server/src/jobs/database/validateModels.ts +++ b/server/src/jobs/database/validateModels.ts @@ -21,7 +21,6 @@ import { ZUnsubscribeOF, ZUnsubscribedLbaCompany, ZUser, - ZUserRecruteur, zFormationCatalogueSchema, } from "shared/models" import { ZodType } from "zod" @@ -50,7 +49,6 @@ import { UnsubscribeOF, UnsubscribedLbaCompany, User, - UserRecruteur, eligibleTrainingsForAppointmentHistory, } from "@/common/model/index" import { Pagination } from "@/common/model/schema/_shared/mongoose-paginate" @@ -120,6 +118,5 @@ export async function validateModels(): Promise { await validateModel(ReferentielOpco, ZReferentielOpco) await validateModel(UnsubscribeOF, ZUnsubscribeOF) await validateModel(UnsubscribedLbaCompany, ZUnsubscribedLbaCompany) - await validateModel(UserRecruteur, ZUserRecruteur) await validateModel(eligibleTrainingsForAppointmentHistory, ZEligibleTrainingsForAppointmentSchema) } diff --git a/server/tests/unit/security/accessTokenService.test.ts b/server/tests/unit/security/accessTokenService.test.ts index c3a9a3a7f9..5225153424 100644 --- a/server/tests/unit/security/accessTokenService.test.ts +++ b/server/tests/unit/security/accessTokenService.test.ts @@ -1,25 +1,50 @@ -import { IUserRecruteur, zRoutes } from "shared" -import { ENTREPRISE, ETAT_UTILISATEUR } from "shared/constants/recruteur" +import { zRoutes } from "shared" import { z } from "shared/helpers/zodWithOpenApi" +import { EntrepriseStatus } from "shared/models/entreprise.model" +import { AccessStatus } from "shared/models/roleManagement.model" +import { IUser2 } from "shared/models/user2.model" import { describe, expect, it } from "vitest" +import { entrepriseStatusEventFactory, roleManagementEventFactory, saveEntrepriseUserTest } from "@tests/utils/user.utils" + import { SchemaWithSecurity, generateAccessToken, generateScope, parseAccessToken } from "../../../src/security/accessTokenService" import { useMongo } from "../../utils/mongo.utils" -import { createUserRecruteurTest } from "../../utils/user.utils" describe("accessTokenService", () => { - let userACTIVE: IUserRecruteur - let userPENDING: IUserRecruteur - let userDISABLED: IUserRecruteur - let userERROR: IUserRecruteur + let userACTIVE: IUser2 + let userPENDING: IUser2 + let userDISABLED: IUser2 + let userERROR: IUser2 let userCFA let userLbaCompany + const saveEntrepriseUserWithStatus = async (status: AccessStatus) => { + const result = await saveEntrepriseUserTest( + {}, + { + status: [ + roleManagementEventFactory({ + status, + }), + ], + } + ) + return result.user + } + const mockData = async () => { - userACTIVE = await createUserRecruteurTest({ type: ENTREPRISE }, ETAT_UTILISATEUR.VALIDE) - userPENDING = await createUserRecruteurTest({ type: ENTREPRISE }, ETAT_UTILISATEUR.ATTENTE) - userDISABLED = await createUserRecruteurTest({ type: ENTREPRISE }, ETAT_UTILISATEUR.DESACTIVE) - userERROR = await createUserRecruteurTest({ type: ENTREPRISE }, ETAT_UTILISATEUR.ERROR) + userACTIVE = await saveEntrepriseUserWithStatus(AccessStatus.GRANTED) + userPENDING = await saveEntrepriseUserWithStatus(AccessStatus.AWAITING_VALIDATION) + userDISABLED = await saveEntrepriseUserWithStatus(AccessStatus.DENIED) + userERROR = ( + await saveEntrepriseUserTest( + {}, + {}, + { + status: [entrepriseStatusEventFactory({ status: EntrepriseStatus.ERROR })], + } + ) + ).user userCFA = { type: "cfa" as const, email: "plop@gmail.com", siret: "12343154300012" } userLbaCompany = { type: "lba-company", email: "plop@gmail.com", siret: "12343154300012" } } diff --git a/server/tests/utils/user.utils.ts b/server/tests/utils/user.utils.ts index a50cc907c7..bb9e7c2468 100644 --- a/server/tests/utils/user.utils.ts +++ b/server/tests/utils/user.utils.ts @@ -1,15 +1,15 @@ -import { ETAT_UTILISATEUR, OPCOS, RECRUITER_STATUS, VALIDATION_UTILISATEUR } from "shared/constants/recruteur" +import { OPCOS, RECRUITER_STATUS, VALIDATION_UTILISATEUR } from "shared/constants/recruteur" import { extensions } from "shared/helpers/zodHelpers/zodPrimitives" -import { IApplication, ICredential, IEmailBlacklist, IJob, IRecruiter, IUserRecruteur, JOB_STATUS, ZApplication, ZCredential, ZEmailBlacklist, ZUserRecruteur } from "shared/models" +import { IApplication, ICredential, IEmailBlacklist, IJob, IRecruiter, JOB_STATUS, ZApplication, ZCredential, ZEmailBlacklist } from "shared/models" import { ICFA, zCFA } from "shared/models/cfa.model" import { zObjectId } from "shared/models/common" -import { IEntreprise, ZEntreprise } from "shared/models/entreprise.model" +import { EntrepriseStatus, IEntreprise, IEntrepriseStatusEvent, ZEntreprise } from "shared/models/entreprise.model" import { AccessEntityType, AccessStatus, IRoleManagement, IRoleManagementEvent } from "shared/models/roleManagement.model" import { IUser2, ZUser2 } from "shared/models/user2.model" import { ZodObject, ZodString, ZodTypeAny } from "zod" import { Fixture, Generator } from "zod-fixture" -import { Application, Cfa, Credential, EmailBlacklist, Entreprise, Recruiter, RoleManagement, User2, UserRecruteur } from "@/common/model" +import { Application, Cfa, Credential, EmailBlacklist, Entreprise, Recruiter, RoleManagement, User2 } from "@/common/model" import { ObjectId } from "@/common/mongodb" let seed = 0 @@ -99,6 +99,17 @@ export const roleManagementEventFactory = ({ export const saveEntreprise = async (data: Partial = {}) => { return saveDbEntity(ZEntreprise, (item) => new Entreprise(item), data) } + +export const entrepriseStatusEventFactory = (props: Partial = {}): IEntrepriseStatusEvent => { + return { + date: new Date(), + reason: "test", + status: EntrepriseStatus.VALIDE, + validation_type: VALIDATION_UTILISATEUR.AUTO, + ...props, + } +} + export const saveCfa = async (data: Partial = {}) => { return saveDbEntity(zCFA, (item) => new Cfa(item), data) } @@ -140,16 +151,6 @@ export const jobFactory = (props: Partial = {}) => { return job } -export async function createUserRecruteurTest(data: Partial, userState: string = ETAT_UTILISATEUR.VALIDE) { - const u = new UserRecruteur({ - ...getFixture().fromSchema(ZUserRecruteur), - status: [{ validation_type: "AUTOMATIQUE", status: userState }], - ...data, - }) - await u.save() - return u -} - export async function createCredentialTest(data: Partial) { const u = new Credential({ ...getFixture().fromSchema(ZCredential), @@ -227,14 +228,15 @@ export const saveAdminUserTest = async (userProps: Partial = {}) => { return { user, role } } -export const saveEntrepriseUserTest = async () => { - const user = await saveUser2() - const entreprise = await saveEntreprise() +export const saveEntrepriseUserTest = async (userProps: Partial = {}, roleProps: Partial = {}, entrepriseProps: Partial = {}) => { + const user = await saveUser2(userProps) + const entreprise = await saveEntreprise(entrepriseProps) const role = await saveRoleManagement({ user_id: user._id, authorized_id: entreprise._id.toString(), authorized_type: AccessEntityType.ENTREPRISE, status: [roleManagementEventFactory()], + ...roleProps, }) const recruiter = await saveRecruiter({ is_delegated: false, From 8b8785eca6950966976a0cab6553a0dae4e4ff2e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9my=20Auricoste?= Date: Wed, 27 Mar 2024 16:16:31 +0100 Subject: [PATCH 63/78] fix: token tests --- server/src/security/accessTokenService.ts | 2 +- .../unit/security/accessTokenService.test.ts | 51 +++++++++++-------- 2 files changed, 31 insertions(+), 22 deletions(-) diff --git a/server/src/security/accessTokenService.ts b/server/src/security/accessTokenService.ts index 021a52f4b4..090c87885a 100644 --- a/server/src/security/accessTokenService.ts +++ b/server/src/security/accessTokenService.ts @@ -84,7 +84,7 @@ export type UserForAccessToken = IUserRecruteur | IAccessToken["identity"] export const user2ToUserForToken = (user: IUser2): IUser2ForAccessToken => ({ type: "IUser2", _id: user._id.toString(), email: user.email }) export function generateAccessToken(user: UserForAccessToken, scopes: ReadonlyArray>, options: { expiresIn?: string } = {}): string { - const identity: IAccessToken["identity"] = "_id" in user ? { type: "IUserRecruteur", _id: user._id.toString(), email: user.email.toLowerCase() } : user + const identity: IAccessToken["identity"] = "_id" in user ? { type: "IUser2", _id: user._id.toString(), email: user.email.toLowerCase() } : user const data: IAccessToken = { identity, scopes, diff --git a/server/tests/unit/security/accessTokenService.test.ts b/server/tests/unit/security/accessTokenService.test.ts index 5225153424..49b050c0d5 100644 --- a/server/tests/unit/security/accessTokenService.test.ts +++ b/server/tests/unit/security/accessTokenService.test.ts @@ -2,19 +2,26 @@ import { zRoutes } from "shared" import { z } from "shared/helpers/zodWithOpenApi" import { EntrepriseStatus } from "shared/models/entreprise.model" import { AccessStatus } from "shared/models/roleManagement.model" -import { IUser2 } from "shared/models/user2.model" import { describe, expect, it } from "vitest" import { entrepriseStatusEventFactory, roleManagementEventFactory, saveEntrepriseUserTest } from "@tests/utils/user.utils" -import { SchemaWithSecurity, generateAccessToken, generateScope, parseAccessToken } from "../../../src/security/accessTokenService" +import { + IUser2ForAccessToken, + SchemaWithSecurity, + UserForAccessToken, + generateAccessToken, + generateScope, + parseAccessToken, + user2ToUserForToken, +} from "../../../src/security/accessTokenService" import { useMongo } from "../../utils/mongo.utils" describe("accessTokenService", () => { - let userACTIVE: IUser2 - let userPENDING: IUser2 - let userDISABLED: IUser2 - let userERROR: IUser2 + let userACTIVE: IUser2ForAccessToken + let userPENDING: IUser2ForAccessToken + let userDISABLED: IUser2ForAccessToken + let userERROR: IUser2ForAccessToken let userCFA let userLbaCompany @@ -33,18 +40,20 @@ describe("accessTokenService", () => { } const mockData = async () => { - userACTIVE = await saveEntrepriseUserWithStatus(AccessStatus.GRANTED) - userPENDING = await saveEntrepriseUserWithStatus(AccessStatus.AWAITING_VALIDATION) - userDISABLED = await saveEntrepriseUserWithStatus(AccessStatus.DENIED) - userERROR = ( - await saveEntrepriseUserTest( - {}, - {}, - { - status: [entrepriseStatusEventFactory({ status: EntrepriseStatus.ERROR })], - } - ) - ).user + userACTIVE = user2ToUserForToken(await saveEntrepriseUserWithStatus(AccessStatus.GRANTED)) + userPENDING = user2ToUserForToken(await saveEntrepriseUserWithStatus(AccessStatus.AWAITING_VALIDATION)) + userDISABLED = user2ToUserForToken(await saveEntrepriseUserWithStatus(AccessStatus.DENIED)) + userERROR = user2ToUserForToken( + ( + await saveEntrepriseUserTest( + {}, + {}, + { + status: [entrepriseStatusEventFactory({ status: EntrepriseStatus.ERROR })], + } + ) + ).user + ) userCFA = { type: "cfa" as const, email: "plop@gmail.com", siret: "12343154300012" } userLbaCompany = { type: "lba-company", email: "plop@gmail.com", siret: "12343154300012" } } @@ -77,11 +86,11 @@ describe("accessTokenService", () => { skip: "3", }, } as const - const expectTokenValid = (token: string) => expect(parseAccessToken(token, schema, options.params, options.querystring)).toBeTruthy() + const expectTokenValid = (token: string) => expect(parseAccessToken(token, schema, options.params, options.querystring)).resolves.toBeTruthy() const expectTokenInvalid = (token: string) => expect(() => parseAccessToken(token, schema, options.params, options.querystring)).rejects.toThrow() describe("valid tokens", () => { - describe.each([ + describe.each<[string, () => UserForAccessToken]>([ ["ACTIVE user", () => userACTIVE], ["CFA", () => userCFA], ["LBA COMPANY user", () => userLbaCompany], @@ -115,7 +124,7 @@ describe("accessTokenService", () => { }) }) describe("invalid tokens", () => { - describe.each([ + describe.each<[string, () => UserForAccessToken]>([ ["ERROR user", () => userERROR], ["PENDING user", () => userPENDING], ["DISABLED user", () => userDISABLED], From 297652e2bb985a6db13529ea782847c51f70e74d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9my=20Auricoste?= Date: Thu, 28 Mar 2024 10:22:20 +0100 Subject: [PATCH 64/78] fix: refactor update user --- .../etablissementRecruteur.controller.ts | 7 ++-- .../src/http/controllers/user.controller.ts | 36 ++++++++----------- server/src/services/userRecruteur.service.ts | 24 ++++++++++--- shared/constants/errorCodes.ts | 1 + shared/routes/user.routes.ts | 19 ++++++---- shared/utils/objectUtils.ts | 4 +++ .../Admin/utilisateurs/AdminUserForm.tsx | 9 ++--- .../ConfirmationDesactivationUtilisateur.tsx | 2 +- .../entreprise/[establishment_id]/edition.tsx | 4 +-- .../opco/entreprise/[siret_userId]/index.tsx | 4 +-- .../administration/users/[userId].tsx | 4 +-- ui/utils/api.ts | 23 ++++-------- ui/utils/api.utils.ts | 4 --- 13 files changed, 71 insertions(+), 70 deletions(-) diff --git a/server/src/http/controllers/etablissementRecruteur.controller.ts b/server/src/http/controllers/etablissementRecruteur.controller.ts index 9d396321c7..10395912ae 100644 --- a/server/src/http/controllers/etablissementRecruteur.controller.ts +++ b/server/src/http/controllers/etablissementRecruteur.controller.ts @@ -5,7 +5,7 @@ import { RECRUITER_STATUS } from "shared/constants/recruteur" import { UserEventType } from "shared/models/user2.model" import { getLastStatusEvent } from "shared/utils/getLastStatusEvent" -import { Cfa, Recruiter, User2 } from "@/common/model" +import { Cfa, Recruiter } from "@/common/model" import { startSession } from "@/common/utils/session.service" import config from "@/config" import { user2ToUserForToken } from "@/security/accessTokenService" @@ -256,11 +256,10 @@ export default (server: Server) => { }, async (req, res) => { const { _id, ...rest } = req.body - const exists = await User2.findOne({ email: req.body.email.toLocaleLowerCase(), _id: { $ne: _id } }) - if (exists) { + const result = await updateUser2Fields(req.params.id, rest) + if ("error" in result) { throw Boom.badRequest("L'adresse mail est déjà associée à un compte La bonne alternance.") } - await updateUser2Fields(req.params.id, rest) return res.status(200).send({ ok: true }) } ) diff --git a/server/src/http/controllers/user.controller.ts b/server/src/http/controllers/user.controller.ts index dd15e3a51e..e6b42c317b 100644 --- a/server/src/http/controllers/user.controller.ts +++ b/server/src/http/controllers/user.controller.ts @@ -101,26 +101,23 @@ export default (server: Server) => { ) server.put( - "/admin/users/:userId", + "/admin/users/:userId/organization/:siret", { - schema: zRoutes.put["/admin/users/:userId"], - onRequest: [server.auth(zRoutes.put["/admin/users/:userId"])], + schema: zRoutes.put["/admin/users/:userId/organization/:siret"], + onRequest: [server.auth(zRoutes.put["/admin/users/:userId/organization/:siret"])], }, async (req, res) => { - const { email } = req.body - const { userId } = req.params - const newEmail = email?.toLocaleLowerCase() - - if (newEmail) { - const exist = await User2.findOne({ email: newEmail, _id: { $ne: userId } }).lean() - if (exist) { - return res.status(400).send({ error: true, reason: "EMAIL_TAKEN" }) - } + const { userId, siret } = req.params + const { opco, ...userFields } = req.body + const result = await updateUser2Fields(userId, userFields) + if ("error" in result) { + return res.status(400).send({ error: true, reason: "EMAIL_TAKEN" }) } - - const updatedUser = await updateUser2Fields(userId, req.body) - if (!updatedUser) { - throw Boom.internal(`could not update one user from query=${JSON.stringify({ _id: userId })}`) + if (opco) { + const entreprise = await Entreprise.findOneAndUpdate({ siret }, { opco }).lean() + if (!entreprise) { + throw Boom.badRequest(`pas d'entreprise ayant le siret ${siret}`) + } } return res.status(200).send({ ok: true }) } @@ -249,14 +246,11 @@ export default (server: Server) => { onRequest: [server.auth(zRoutes.put["/user/:userId"])], }, async (req, res) => { - const { email } = req.body const { userId } = req.params - const formattedEmail = email?.toLocaleLowerCase() - const exist = await User2.findOne({ email: formattedEmail, _id: { $ne: userId } }).lean() - if (exist) { + const result = await updateUser2Fields(userId, req.body) + if ("error" in result) { return res.status(400).send({ error: true, reason: "EMAIL_TAKEN" }) } - await updateUser2Fields(userId, req.body) const user = await getUserRecruteurById(userId) return res.status(200).send(user) } diff --git a/server/src/services/userRecruteur.service.ts b/server/src/services/userRecruteur.service.ts index 93a1870067..997d7adeb9 100644 --- a/server/src/services/userRecruteur.service.ts +++ b/server/src/services/userRecruteur.service.ts @@ -1,7 +1,8 @@ import { randomUUID } from "crypto" import Boom from "boom" -import { IRecruiter, IUserRecruteur, IUserRecruteurForAdmin, IUserStatusValidation, assertUnreachable, parseEnumOrError } from "shared" +import { IRecruiter, IUserRecruteur, IUserRecruteurForAdmin, IUserStatusValidation, assertUnreachable, parseEnumOrError, removeUndefinedFields } from "shared" +import { BusinessErrorCodes } from "shared/constants/errorCodes" import { CFA, ENTREPRISE, ETAT_UTILISATEUR, OPCOS, VALIDATION_UTILISATEUR } from "shared/constants/recruteur" import { ICFA } from "shared/models/cfa.model" import { EntrepriseStatus, IEntreprise, IEntrepriseStatusEvent } from "shared/models/entreprise.model" @@ -13,7 +14,7 @@ import { ObjectId, ObjectIdType } from "@/common/mongodb" import { getStaticFilePath } from "@/common/utils/getStaticFilePath" import { user2ToUserForToken } from "@/security/accessTokenService" -import { Cfa, Entreprise, RoleManagement, User2 } from "../common/model/index" +import { Cfa, Entreprise, Recruiter, RoleManagement, User2 } from "../common/model/index" import config from "../config" import { createAuthMagicLink } from "./appLinks.service" @@ -266,9 +267,22 @@ export const createUser = async ( } } -export const updateUser2Fields = (userId: ObjectIdType, fields: Partial) => { - const { email, ...otherFields } = fields - return User2.findOneAndUpdate({ _id: userId }, { ...otherFields, ...(email ? { email: email.toLocaleLowerCase() } : {}) }, { new: true }) +export const updateUser2Fields = async (userId: ObjectIdType, fields: Partial): Promise => { + const { email, first_name, last_name, phone } = fields + const newEmail = email?.toLocaleLowerCase() + + if (newEmail) { + const exist = await User2.findOne({ email: newEmail, _id: { $ne: userId } }).lean() + if (exist) { + return { error: BusinessErrorCodes.EMAIL_ALREADY_EXISTS } + } + } + const newUser = await User2.findOneAndUpdate({ _id: userId }, removeUndefinedFields({ ...fields, email: newEmail }), { new: true }).lean() + if (!newUser) { + throw Boom.badRequest("user not found") + } + await Recruiter.updateMany({ "jobs.managed_by": userId.toString() }, { $set: removeUndefinedFields({ first_name, last_name, phone, email: newEmail }) }) + return newUser } export const validateUserEmail = async (userId: ObjectIdType) => { diff --git a/shared/constants/errorCodes.ts b/shared/constants/errorCodes.ts index 2e08308412..bd64bb5198 100644 --- a/shared/constants/errorCodes.ts +++ b/shared/constants/errorCodes.ts @@ -1,6 +1,7 @@ export enum BusinessErrorCodes { IS_CFA = "IS_CFA", ALREADY_EXISTS = "ALREADY_EXISTS", + EMAIL_ALREADY_EXISTS = "EMAIL_ALREADY_EXISTS", CLOSED = "CLOSED", NON_DIFFUSIBLE = "NON_DIFFUSIBLE", UNKNOWN = "UNKNOWN", diff --git a/shared/routes/user.routes.ts b/shared/routes/user.routes.ts index 36ed8f32a8..7ff577b651 100644 --- a/shared/routes/user.routes.ts +++ b/shared/routes/user.routes.ts @@ -5,7 +5,7 @@ import { zObjectId } from "../models/common" import { enumToZod } from "../models/enumToZod" import { AccessEntityType, ZRoleManagement, ZRoleManagementEvent } from "../models/roleManagement.model" import { ZUser2 } from "../models/user2.model" -import { ZEtatUtilisateur, ZUserRecruteur, ZUserRecruteurForAdmin, ZUserRecruteurWritable } from "../models/usersRecruteur.model" +import { ZEtatUtilisateur, ZUserRecruteur, ZUserRecruteurForAdmin } from "../models/usersRecruteur.model" import { IRoutesDef, ZResError } from "./common.routes" @@ -208,7 +208,7 @@ export const zUserRecruteurRoutes = { method: "put", path: "/user/:userId", params: z.object({ userId: zObjectId }).strict(), - body: ZUserRecruteurWritable.pick({ + body: ZUser2.pick({ last_name: true, first_name: true, phone: true, @@ -226,13 +226,18 @@ export const zUserRecruteurRoutes = { }, }, }, - "/admin/users/:userId": { + "/admin/users/:userId/organization/:siret": { method: "put", - path: "/admin/users/:userId", - params: z.object({ userId: zObjectId }).strict(), - body: ZUserRecruteurWritable.omit({ + path: "/admin/users/:userId/organization/:siret", + params: z.object({ userId: zObjectId, siret: z.string() }).strict(), + body: ZUser2.omit({ status: true, - }).partial(), + _id: true, + }) + .extend({ + opco: ZUserRecruteur.shape.opco, + }) + .partial(), response: { "200": z.object({ ok: z.boolean() }).strict(), "400": z.union([ZResError, z.object({ error: z.boolean(), reason: z.string() }).strict()]), diff --git a/shared/utils/objectUtils.ts b/shared/utils/objectUtils.ts index 01d2aaf4a2..1ebcd26e9a 100644 --- a/shared/utils/objectUtils.ts +++ b/shared/utils/objectUtils.ts @@ -3,3 +3,7 @@ export const typedKeys = (record: Record(entries: [A, B][]): Record => { return Object.fromEntries(entries) as Record } + +export function removeUndefinedFields>(obj: T): T { + return Object.fromEntries(Object.entries(obj).filter(([_, value]) => value !== undefined)) as T +} diff --git a/ui/components/espace_pro/Admin/utilisateurs/AdminUserForm.tsx b/ui/components/espace_pro/Admin/utilisateurs/AdminUserForm.tsx index ff2dc5e750..c29f25639d 100644 --- a/ui/components/espace_pro/Admin/utilisateurs/AdminUserForm.tsx +++ b/ui/components/espace_pro/Admin/utilisateurs/AdminUserForm.tsx @@ -6,8 +6,8 @@ import { IUser2Json } from "shared/models/user2.model" import * as Yup from "yup" import { useUserPermissionsActions } from "@/common/hooks/useUserPermissionsActions" -import { createAdminUser } from "@/utils/api" -import { apiDelete, apiPut } from "@/utils/api.utils" +import { createAdminUser, updateEntrepriseAdmin } from "@/utils/api" +import { apiDelete } from "@/utils/api.utils" import CustomInput from "../../CustomInput" @@ -46,10 +46,7 @@ export const AdminUserForm = ({ try { if (user) { - result = await apiPut("/admin/users/:userId", { - params: { userId: user._id.toString() }, - body: values, - }) + result = await updateEntrepriseAdmin(user._id.toString(), values) if (result?.ok) { toast({ title: "Utilisateur mis à jour", diff --git a/ui/components/espace_pro/ConfirmationDesactivationUtilisateur.tsx b/ui/components/espace_pro/ConfirmationDesactivationUtilisateur.tsx index 1115b6d65f..01da56bbd7 100644 --- a/ui/components/espace_pro/ConfirmationDesactivationUtilisateur.tsx +++ b/ui/components/espace_pro/ConfirmationDesactivationUtilisateur.tsx @@ -50,7 +50,7 @@ export const ConfirmationDesactivationUtilisateur = ({ switch (type) { case AUTHTYPE.ENTREPRISE: if (reason === "Ne relève pas des champs de compétences de mon OPCO") { - await Promise.all([updateEntrepriseAdmin(_id, establishment_id, { opco: "inconnu" }), reassignUserToAdmin(reason)]) + await Promise.all([updateEntrepriseAdmin(_id, { opco: "inconnu" }, establishment_siret), reassignUserToAdmin(reason)]) } else { await Promise.all([archiveFormulaire(establishment_id), disableUser(reason)]) } diff --git a/ui/pages/espace-pro/administration/entreprise/[establishment_id]/edition.tsx b/ui/pages/espace-pro/administration/entreprise/[establishment_id]/edition.tsx index 6b375ecf62..3b76ec1927 100644 --- a/ui/pages/espace-pro/administration/entreprise/[establishment_id]/edition.tsx +++ b/ui/pages/espace-pro/administration/entreprise/[establishment_id]/edition.tsx @@ -35,7 +35,7 @@ const Formulaire = ({ * KBA 20230511 : values for recruiter collection are casted in api.js file directly. form values must remain as awaited in userRecruteur collection */ // TODO - const entrepriseMutation = useMutation(({ userId, establishment_id, values }) => updateEntreprise(userId, establishment_id, values), { + const entrepriseMutation = useMutation(({ userId, values }) => updateEntreprise(userId, values), { onSuccess: () => { toast({ title: "Entreprise mise à jour avec succès.", @@ -67,7 +67,7 @@ const Formulaire = ({ const submitForm = async (values, { setSubmitting, setFieldError }) => { if (user.type === AUTHTYPE.ENTREPRISE) { entrepriseMutation.mutate( - { userId: user._id, establishment_id: establishment_id, values }, + { userId: user._id, values }, { onError: (error: any) => { switch (error.response.data.reason) { diff --git a/ui/pages/espace-pro/administration/opco/entreprise/[siret_userId]/index.tsx b/ui/pages/espace-pro/administration/opco/entreprise/[siret_userId]/index.tsx index 0520edb144..c67a9ceef1 100644 --- a/ui/pages/espace-pro/administration/opco/entreprise/[siret_userId]/index.tsx +++ b/ui/pages/espace-pro/administration/opco/entreprise/[siret_userId]/index.tsx @@ -60,7 +60,7 @@ function DetailEntreprise() { cacheTime: 0, }) - const userMutation = useMutation(({ userId, establishment_id, values }: any) => updateEntrepriseAdmin(userId, establishment_id, values), { + const userMutation = useMutation(({ userId, values }: any) => updateEntrepriseAdmin(userId, values, userRecruteur.establishment_siret), { onSuccess: () => { client.invalidateQueries("user") }, @@ -211,7 +211,7 @@ function DetailEntreprise() { onSubmit={async (values, { setSubmitting }) => { setSubmitting(true) // For companies we update the User Collection and the Formulaire collection at the same time - userMutation.mutate({ userId: userRecruteur._id, establishment_id: userRecruteur.establishment_id, values }) + userMutation.mutate({ userId: userRecruteur._id, values }) toast({ title: "Mise à jour enregistrée avec succès", position: "top-right", diff --git a/ui/pages/espace-pro/administration/users/[userId].tsx b/ui/pages/espace-pro/administration/users/[userId].tsx index 51a395bcc3..eb0addf750 100644 --- a/ui/pages/espace-pro/administration/users/[userId].tsx +++ b/ui/pages/espace-pro/administration/users/[userId].tsx @@ -120,7 +120,7 @@ function DetailEntreprise() { const { data: userRecruteur, isLoading } = useQuery("user", () => getUser(userId), { cacheTime: 0, enabled: !!userId }) // @ts-expect-error: TODO - const userMutation = useMutation(({ userId, establishment_id, values }) => updateEntrepriseAdmin(userId, establishment_id, values), { + const userMutation = useMutation(({ userId, values }) => updateEntrepriseAdmin(userId, values, userRecruteur.establishment_siret), { onSuccess: () => { client.invalidateQueries("user") }, @@ -209,7 +209,7 @@ function DetailEntreprise() { setSubmitting(true) // For companies we update the User Collection and the Formulaire collection at the same time // @ts-expect-error: TODO - userMutation.mutate({ userId: userRecruteur._id, establishment_id: userRecruteur.establishment_id, values }) + userMutation.mutate({ userId: userRecruteur._id, values }) toast({ title: "Mise à jour enregistrée avec succès", position: "top-right", diff --git a/ui/utils/api.ts b/ui/utils/api.ts index f0031eabb4..3e98cfe858 100644 --- a/ui/utils/api.ts +++ b/ui/utils/api.ts @@ -1,6 +1,6 @@ import { captureException } from "@sentry/nextjs" import Axios from "axios" -import { IJobWritable, INewDelegations, IRoutes, parseEnumOrError } from "shared" +import { IJobWritable, INewDelegations, IRoutes, parseEnumOrError, removeUndefinedFields } from "shared" import { BusinessErrorCodes } from "shared/constants/errorCodes" import { OPCOS } from "shared/constants/recruteur" import { AccessEntityType, AccessStatus } from "shared/models/roleManagement.model" @@ -9,7 +9,7 @@ import { IEntrepriseInformations } from "shared/routes/recruiters.routes" import { publicConfig } from "../config.public" -import { ApiError, apiDelete, apiGet, apiPost, apiPut, removeUndefinedFields } from "./api.utils" +import { ApiError, apiDelete, apiGet, apiPost, apiPut } from "./api.utils" const API = Axios.create({ baseURL: publicConfig.apiEndpoint, @@ -58,8 +58,6 @@ export const createEtablissementDelegationByToken = ({ data, jobId, token }: { j * User API */ export const getUser = (userId: string, organizationId: string = "unused") => apiGet("/user/:userId/organization/:organizationId", { params: { userId, organizationId } }) -const updateUser = (userId: string, user) => apiPut("/user/:userId", { params: { userId }, body: user }) -const updateUserAdmin = (userId: string, user) => apiPut("/admin/users/:userId", { params: { userId }, body: user }) export const getUserStatus = (userId: string) => apiGet("/user/status/:userId", { params: { userId } }) export const getUserStatusByToken = (userId: string, token: string) => apiGet("/user/status/:userId/by-token", { params: { userId }, headers: { authorization: `Bearer ${token}` } }) @@ -84,20 +82,13 @@ export const createAdminUser = (user: IUser2) => apiPost("/admin/users", { body: /** * KBA 20230511 : (migration db) : casting des valueurs coté collection recruiter, car les champs ne sont plus identiques avec la collection userRecruteur. */ -export const updateEntreprise = async (userId: string, establishment_id: string | undefined, user: any) => { - const promises: Promise[] = [updateUser(userId, user)] - if (establishment_id) { - promises.push(updateFormulaire(establishment_id, user)) - } - await Promise.all(promises) +export const updateEntreprise = async (userId: string, user: any) => { + await apiPut("/user/:userId", { params: { userId }, body: user }) } -export const updateEntrepriseAdmin = async (userId: string, establishment_id: string, user: any) => - await Promise.all([ - updateUserAdmin(userId, user), - // - updateFormulaire(establishment_id, user), - ]) +export const updateEntrepriseAdmin = async (userId: string, user: any, siret = "unused") => { + await apiPut("/admin/users/:userId/organization/:siret", { params: { userId, siret }, body: user }) +} /** * Auth API diff --git a/ui/utils/api.utils.ts b/ui/utils/api.utils.ts index fb9e6f28cc..e116d8d0c7 100644 --- a/ui/utils/api.utils.ts +++ b/ui/utils/api.utils.ts @@ -207,7 +207,3 @@ export async function apiDelete

>(obj: T): T { - return Object.fromEntries(Object.entries(obj).filter(([_, value]) => value !== undefined)) as T -} From dd680555e2d003887b3ef6881159540a20924a1b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9my=20Auricoste?= Date: Thu, 28 Mar 2024 10:53:45 +0100 Subject: [PATCH 65/78] fix: update db --- .infra/files/configs/mongodb/seed.gpg | 4 ++-- .talismanrc | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.infra/files/configs/mongodb/seed.gpg b/.infra/files/configs/mongodb/seed.gpg index d9c1c24ac4..c8f54bc053 100644 --- a/.infra/files/configs/mongodb/seed.gpg +++ b/.infra/files/configs/mongodb/seed.gpg @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:72ca7960eba2339534e4b7fa7805a6dfbb48241d3fbaf104cd68392f5ef30f78 -size 281744734 +oid sha256:95b1a9c69b4f56c04974edcb460f0bdb6ef5bf0d2e53d8d80309ed2044026c71 +size 281858696 diff --git a/.talismanrc b/.talismanrc index 2065a87db9..745c70d4c6 100644 --- a/.talismanrc +++ b/.talismanrc @@ -22,7 +22,7 @@ fileignoreconfig: - filename: .infra/files/configs/mongodb/mongod.conf checksum: 718bee5f44edc101636be8f11173ede5b728f2858abc3c26466ff9435f0d11de - filename: .infra/files/configs/mongodb/seed.gpg - checksum: 9c81ab35ef9f955861274817f1a24e8416d64ddabec6db9329244980e92b8921 + checksum: f64104669d7128fa36eaa7f42694ee114014a2cb630bcde3fdd6c2599dfd3c21 - filename: .infra/files/scripts/seed.sh checksum: ddafc86248e8fd5f7c24ca5a62be703083f7704395f17fb7b43bc8e44227d561 - filename: .infra/local/mongod.conf From 443a9fdab91c7fc56d9a65393e9acd61e09b038a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9my=20Auricoste?= Date: Thu, 28 Mar 2024 14:38:30 +0100 Subject: [PATCH 66/78] fix: search rome --- cypress/pages/FlowCreationEntreprise.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/cypress/pages/FlowCreationEntreprise.ts b/cypress/pages/FlowCreationEntreprise.ts index 405814eeae..0bf1737441 100644 --- a/cypress/pages/FlowCreationEntreprise.ts +++ b/cypress/pages/FlowCreationEntreprise.ts @@ -50,8 +50,12 @@ export const FlowCreationEntreprise = { jobCount?: number jobDurationInMonths: number }) { + const typedRomeLabel = romeLabel.substring(0, romeLabel.length - 10) + cy.intercept(`${Cypress.env("server")}/api/v1/metiers/intitule?label=${encodeURI(typedRomeLabel)}`).as("romeSearch") + cy.get("[data-testid='offre-metier'] input").click() - cy.get("[data-testid='offre-metier'] input").type(romeLabel.substring(0, romeLabel.length - 10)) + cy.get("[data-testid='offre-metier'] input").type(typedRomeLabel) + cy.wait("@romeSearch") cy.get(`[data-testid='offre-metier'] #downshift-1-item-0 p:first-of-type`, { timeout: 10000 }).should("have.text", romeLabel) cy.get(`[data-testid='offre-metier'] [data-testid='${romeLabel}']`).click() From 8d172a40f749c5170c4d9456bfa7f0c3aae00a00 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9my=20Auricoste?= Date: Thu, 28 Mar 2024 15:44:05 +0100 Subject: [PATCH 67/78] fix: ajout validation zod de l'adresse venant de l'api entreprise --- server/src/services/etablissement.service.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/server/src/services/etablissement.service.ts b/server/src/services/etablissement.service.ts index f6a4bd2e60..4a2fa81342 100644 --- a/server/src/services/etablissement.service.ts +++ b/server/src/services/etablissement.service.ts @@ -3,7 +3,7 @@ import { setTimeout } from "timers/promises" import { AxiosResponse } from "axios" import Boom from "boom" import type { FilterQuery } from "mongoose" -import { IAdresseV3, IBusinessError, ICfaReferentielData, IEtablissement, ILbaCompany, IRecruiter, IReferentielOpco, ZCfaReferentielData } from "shared" +import { IAdresseV3, IBusinessError, ICfaReferentielData, IEtablissement, ILbaCompany, IRecruiter, IReferentielOpco, ZAdresseV3, ZCfaReferentielData } from "shared" import { EDiffusibleStatus } from "shared/constants/diffusibleStatus" import { BusinessErrorCodes } from "shared/constants/errorCodes" import { EntrepriseStatus } from "shared/models/entreprise.model" @@ -267,6 +267,7 @@ export const getEtablissementFromGouvSafe = async (siret: string): Promise Date: Thu, 28 Mar 2024 15:45:19 +0100 Subject: [PATCH 68/78] =?UTF-8?q?fix:=20ajout=20des=20validations=20des=20?= =?UTF-8?q?nouveaux=20mod=C3=A8les=20dans=20le=20db=20validate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/src/jobs/database/validateModels.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/server/src/jobs/database/validateModels.ts b/server/src/jobs/database/validateModels.ts index 1325511648..c622f6dc68 100644 --- a/server/src/jobs/database/validateModels.ts +++ b/server/src/jobs/database/validateModels.ts @@ -21,8 +21,13 @@ import { ZUnsubscribeOF, ZUnsubscribedLbaCompany, ZUser, + ZUserRecruteur, zFormationCatalogueSchema, } from "shared/models" +import { zCFA } from "shared/models/cfa.model" +import { ZEntreprise } from "shared/models/entreprise.model" +import { ZRoleManagement } from "shared/models/roleManagement.model" +import { ZUser2 } from "shared/models/user2.model" import { ZodType } from "zod" import { logger } from "@/common/logger" @@ -31,11 +36,13 @@ import { Application, Appointment, AppointmentDetailed, + Cfa, Credential, DiplomesMetiers, DomainesMetiers, EligibleTrainingsForAppointment, EmailBlacklist, + Entreprise, Etablissement, FormationCatalogue, GeoLocation, @@ -46,9 +53,12 @@ import { Recruiter, ReferentielOnisep, ReferentielOpco, + RoleManagement, UnsubscribeOF, UnsubscribedLbaCompany, User, + User2, + UserRecruteur, eligibleTrainingsForAppointmentHistory, } from "@/common/model/index" import { Pagination } from "@/common/model/schema/_shared/mongoose-paginate" @@ -119,4 +129,9 @@ export async function validateModels(): Promise { await validateModel(UnsubscribeOF, ZUnsubscribeOF) await validateModel(UnsubscribedLbaCompany, ZUnsubscribedLbaCompany) await validateModel(eligibleTrainingsForAppointmentHistory, ZEligibleTrainingsForAppointmentSchema) + await validateModel(UserRecruteur, ZUserRecruteur) + await validateModel(Entreprise, ZEntreprise) + await validateModel(Cfa, zCFA) + await validateModel(User2, ZUser2) + await validateModel(RoleManagement, ZRoleManagement) } From f2bca734297393aac7c0a86ea7a4a5f254454781 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9my=20Auricoste?= Date: Thu, 28 Mar 2024 16:15:07 +0100 Subject: [PATCH 69/78] fix: address_detail --- server/src/jobs/multiCompte/migrationUsers.ts | 37 +++++++++++++++++-- shared/models/address.model.ts | 25 +++++++------ 2 files changed, 47 insertions(+), 15 deletions(-) diff --git a/server/src/jobs/multiCompte/migrationUsers.ts b/server/src/jobs/multiCompte/migrationUsers.ts index 4607b8eaca..772b3ceb53 100644 --- a/server/src/jobs/multiCompte/migrationUsers.ts +++ b/server/src/jobs/multiCompte/migrationUsers.ts @@ -1,5 +1,5 @@ import dayjs from "dayjs" -import { getLastStatusEvent, IRecruiter, parseEnumOrError } from "shared" +import { getLastStatusEvent, IRecruiter, parseEnumOrError, ZGlobalAddress } from "shared" import { AppointmentUserType } from "shared/constants/appointment.js" import { EApplicantRole } from "shared/constants/rdva.js" import { ENTREPRISE, ETAT_UTILISATEUR, OPCOS, VALIDATION_UTILISATEUR } from "shared/constants/recruteur.js" @@ -111,7 +111,6 @@ const migrationUserRecruteurs = async () => { establishment_raison_sociale, establishment_enseigne, establishment_siret, - address_detail, address, geo_coordinates, phone, @@ -128,6 +127,7 @@ const migrationUserRecruteurs = async () => { createdAt, updatedAt, } = userRecruteur + const oldStatus: IUserRecruteur["status"] | undefined = userRecruteur.status const origin = originRaw || "user migration" index % 1000 === 0 && logger.info(`import du user recruteur n°${index}`) @@ -166,7 +166,15 @@ const migrationUserRecruteurs = async () => { stats.userCreated++ if (type === ENTREPRISE) { if (!establishment_siret) { - throw new Error("inattendu pour une ENTERPRISE: pas de establishment_siret") + throw new Error("inattendu pour une ENTREPRISE: pas de establishment_siret") + } + const address_detail = fixAddressDetail(userRecruteur.address_detail) + checkAddressDetail(address_detail) + if (address_detail !== userRecruteur.address_detail && userRecruteur.establishment_id) { + const updatedRecruiter = await Recruiter.findOneAndUpdate({ establishment_id: userRecruteur.establishment_id }, { address_detail }) + if (!updatedRecruiter) { + throw new Error("impossible de corriger address_detail : recruiter introuvable") + } } const newEntreprise: IEntreprise = { _id: new ObjectId(), @@ -202,6 +210,8 @@ const migrationUserRecruteurs = async () => { if (!establishment_siret) { throw new Error("inattendu pour un CFA: pas de establishment_siret") } + const address_detail = fixAddressDetail(userRecruteur.address_detail) + checkAddressDetail(address_detail) const newCfa: ICFA = { _id: new ObjectId(), siret: establishment_siret, @@ -254,6 +264,8 @@ const migrationUserRecruteurs = async () => { } await RoleManagement.create(roleManagement) stats.opcoAccess++ + } else { + throw new Error(`unsupported type: ${type}`) } stats.success++ } catch (err) { @@ -414,3 +426,22 @@ function userRecruteurStatusToEntrepriseStatus(allStatus: IUserRecruteur["status } return computedStatus } + +const fixAddressDetail = (addressDetail: any) => { + const lFields = ["l1", "l2", "l3", "l4", "l5", "l6", "l7"] + const normalFields = ["numero_voie", "type_voie", "nom_voie", "complement_adresse", "code_postal", "localite", "code_insee_localite", "cedex"] + if (addressDetail && [...lFields, ...normalFields].every((field) => field in addressDetail)) { + return Object.fromEntries([ + ...normalFields.map((field) => [field, addressDetail[field]]), + ["acheminement_postal", Object.fromEntries(lFields.map((field) => [field, addressDetail[field]]))], + ]) + } else { + return addressDetail + } +} + +const checkAddressDetail = (address_detail: any) => { + if (!ZGlobalAddress.safeParse(address_detail).success) { + throw new Error(`address_detail not ok: ${JSON.stringify(address_detail, null, 2)}`) + } +} diff --git a/shared/models/address.model.ts b/shared/models/address.model.ts index ba1ebf5525..fbd1fc1d84 100644 --- a/shared/models/address.model.ts +++ b/shared/models/address.model.ts @@ -68,21 +68,22 @@ export const ZAdresseCFA = z .strict() .openapi("AdresseCFA") -const ZAdresseV2 = ZAcheminementPostal.extend({ - numero_voie: z.string(), - type_voie: z.string(), - nom_voie: z.string(), - complement_adresse: z.string(), - code_postal: z.string(), - localite: z.string(), - code_insee_localite: z.string(), - cedex: z.null(), - acheminement_postal: ZAcheminementPostal.optional(), -}) +const ZAdresseV2 = z + .object({ + numero_voie: z.string().nullish(), + type_voie: z.string().nullish(), + nom_voie: z.string().nullish(), + complement_adresse: z.string().nullish(), + code_postal: z.string().nullish(), + localite: z.string().nullish(), + code_insee_localite: z.string().nullish(), + cedex: z.string().nullish(), + acheminement_postal: ZAcheminementPostal.optional(), + }) .strict() .openapi("AdresseV2") -const ZAdresseV3 = z +export const ZAdresseV3 = z .object({ status_diffusion: z.string().nullish(), complement_adresse: z.string().nullish(), From 2b7f6305ed3fe403d5e6af7759b58beae2c53ffa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9my=20Auricoste?= Date: Thu, 28 Mar 2024 16:17:26 +0100 Subject: [PATCH 70/78] fix: update db --- .infra/files/configs/mongodb/seed.gpg | 4 ++-- .talismanrc | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.infra/files/configs/mongodb/seed.gpg b/.infra/files/configs/mongodb/seed.gpg index c8f54bc053..a010d66823 100644 --- a/.infra/files/configs/mongodb/seed.gpg +++ b/.infra/files/configs/mongodb/seed.gpg @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:95b1a9c69b4f56c04974edcb460f0bdb6ef5bf0d2e53d8d80309ed2044026c71 -size 281858696 +oid sha256:8dfd32dee7ce598d8ae92b5eeb3c169c713e852707b5f0af35adb136135d05d7 +size 281830601 diff --git a/.talismanrc b/.talismanrc index 745c70d4c6..ef2479d82b 100644 --- a/.talismanrc +++ b/.talismanrc @@ -22,7 +22,7 @@ fileignoreconfig: - filename: .infra/files/configs/mongodb/mongod.conf checksum: 718bee5f44edc101636be8f11173ede5b728f2858abc3c26466ff9435f0d11de - filename: .infra/files/configs/mongodb/seed.gpg - checksum: f64104669d7128fa36eaa7f42694ee114014a2cb630bcde3fdd6c2599dfd3c21 + checksum: a5dd4ea34e6be580800014a3ab36be80edf695c3efb4f082e8d494970cb8819f - filename: .infra/files/scripts/seed.sh checksum: ddafc86248e8fd5f7c24ca5a62be703083f7704395f17fb7b43bc8e44227d561 - filename: .infra/local/mongod.conf From cc722bae7e966b481d04d2d796fb2ffa56b555bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9my=20Auricoste?= Date: Tue, 2 Apr 2024 15:06:36 +0200 Subject: [PATCH 71/78] fix: search count on jobs --- server/src/http/controllers/jobs.controller.ts | 4 +++- server/src/http/controllers/jobs.controller.v2.ts | 4 +++- server/src/security/authorisationService.ts | 6 +++--- server/src/services/lbajob.service.ts | 4 ++-- 4 files changed, 11 insertions(+), 7 deletions(-) diff --git a/server/src/http/controllers/jobs.controller.ts b/server/src/http/controllers/jobs.controller.ts index 2c834ff731..6c982f8555 100644 --- a/server/src/http/controllers/jobs.controller.ts +++ b/server/src/http/controllers/jobs.controller.ts @@ -356,7 +356,9 @@ export default (server: Server) => { if ("matchas" in result && result.matchas) { const { matchas } = result - await incrementLbaJobsViewCount(matchas) + if ("results" in matchas) { + await incrementLbaJobsViewCount(matchas.results.flatMap((job) => (job.job?.id ? [job.job.id] : []))) + } } return res.status(200).send(result) diff --git a/server/src/http/controllers/jobs.controller.v2.ts b/server/src/http/controllers/jobs.controller.v2.ts index 7f45fe5efb..9e2a5e32c7 100644 --- a/server/src/http/controllers/jobs.controller.v2.ts +++ b/server/src/http/controllers/jobs.controller.v2.ts @@ -351,7 +351,9 @@ export default (server: Server) => { if ("matchas" in result && result.matchas) { const { matchas } = result - await incrementLbaJobsViewCount(matchas) + if ("results" in matchas) { + await incrementLbaJobsViewCount(matchas.results.flatMap((job) => (job.job?.id ? [job.job.id] : []))) + } } return res.status(200).send(result) diff --git a/server/src/security/authorisationService.ts b/server/src/security/authorisationService.ts index 29bf77aab2..5469161de9 100644 --- a/server/src/security/authorisationService.ts +++ b/server/src/security/authorisationService.ts @@ -200,7 +200,7 @@ async function getResources(schema: S, req: IReque } } -function canAccessRecruiter(userAccess: ComputedUserAccess, resource: Resources["recruiters"][number]): boolean { +function canAccessRecruiter(userAccess: ComputedUserAccess, resource: RecruiterResource): boolean { const recruiterOpco = parseEnum(OPCOS, resource.recruiter.opco ?? null) if (recruiterOpco && userAccess.opcos.includes(recruiterOpco)) { return true @@ -213,7 +213,7 @@ function canAccessRecruiter(userAccess: ComputedUserAccess, resource: Resources[ return false } -function canAccessJob(userAccess: ComputedUserAccess, resource: Resources["jobs"][number]): boolean { +function canAccessJob(userAccess: ComputedUserAccess, resource: JobResource): boolean { return canAccessRecruiter(userAccess, resource.recruiterResource) } @@ -224,7 +224,7 @@ function canAccessUser(userAccess: ComputedUserAccess, resource: Resources["user return userAccess.users.includes(resource._id) } -function canAccessApplication(userAccess: ComputedUserAccess, resource: Resources["applications"][number]): boolean { +function canAccessApplication(userAccess: ComputedUserAccess, resource: ApplicationResource): boolean { const { jobResource, applicantId } = resource // TODO ajout de granularité pour les accès candidat et recruteur return (jobResource && canAccessJob(userAccess, jobResource)) || (applicantId ? canAccessUser(userAccess, { _id: applicantId }) : false) diff --git a/server/src/services/lbajob.service.ts b/server/src/services/lbajob.service.ts index 3152cb6ba3..af49a32e6a 100644 --- a/server/src/services/lbajob.service.ts +++ b/server/src/services/lbajob.service.ts @@ -384,8 +384,8 @@ export const addOffreDetailView = async (jobId: IJob["_id"] | string) => { /** * @description Incrémente les compteurs de vue d'un ensemble d'offres lba */ -export const incrementLbaJobsViewCount = async (lbaJobs) => { - const ids = lbaJobs.results.map((job) => new ObjectId(job.job.id)) +export const incrementLbaJobsViewCount = async (jobIds: string[]) => { + const ids = jobIds.map((id) => new ObjectId(id)) try { await db.collection("recruiters").updateMany( { "jobs._id": { $in: ids } }, From 8302b45f4bf84affe8826d7128c858d2a9ca9372 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9my=20Auricoste?= Date: Tue, 2 Apr 2024 16:22:33 +0200 Subject: [PATCH 72/78] fix: amelioration secu acces user depuis le backoffice opco --- server/src/common/utils/apiGeoAdresse.ts | 2 +- .../jobs/database/fixDiffusibleCompanies.ts | 4 +- server/src/jobs/lbb/updateGeoLocations.ts | 2 +- server/src/jobs/lbb/updateLbaCompanies.ts | 2 +- server/src/security/authorisationService.ts | 44 ++++++++++++++++++- server/src/services/user.service.ts | 3 +- server/src/services/userRecruteur.service.ts | 1 + shared/models/usersRecruteur.model.ts | 4 +- shared/routes/user.routes.ts | 9 +++- shared/security/permissions.ts | 8 ++++ shared/utils/enumUtils.ts | 2 +- .../[entreprise_id].tsx} | 19 ++++---- .../espace-pro/administration/opco/index.tsx | 6 +-- 13 files changed, 81 insertions(+), 25 deletions(-) rename ui/pages/espace-pro/administration/opco/entreprise/[siret_userId]/{index.tsx => entreprise/[entreprise_id].tsx} (95%) diff --git a/server/src/common/utils/apiGeoAdresse.ts b/server/src/common/utils/apiGeoAdresse.ts index 27f86fad08..21ea32890f 100644 --- a/server/src/common/utils/apiGeoAdresse.ts +++ b/server/src/common/utils/apiGeoAdresse.ts @@ -67,7 +67,7 @@ class ApiGeoAdresse { response = await getHttpClient().get(query) if (response?.data?.status === 429) { - console.log("429 ", new Date(), query) + console.warn("429 ", new Date(), query) trys++ await new Promise((resolve) => setTimeout(resolve, 1000)) } else { diff --git a/server/src/jobs/database/fixDiffusibleCompanies.ts b/server/src/jobs/database/fixDiffusibleCompanies.ts index dfcb3dba3c..e6a3c53a19 100644 --- a/server/src/jobs/database/fixDiffusibleCompanies.ts +++ b/server/src/jobs/database/fixDiffusibleCompanies.ts @@ -43,7 +43,7 @@ const fixLbaCompanies = async () => { } const deactivateRecruiter = async (recruiter: IRecruiter) => { - console.log("deactivating non diffusible recruiter : ", recruiter.establishment_siret) + console.info("deactivating non diffusible recruiter : ", recruiter.establishment_siret) recruiter.status = RECRUITER_STATUS.ARCHIVE recruiter.address = ANONYMIZED recruiter.geo_coordinates = FAKE_GEOLOCATION @@ -60,7 +60,7 @@ const deactivateRecruiter = async (recruiter: IRecruiter) => { const deactivateEntreprise = async (entreprise: IEntreprise) => { const { siret } = entreprise - console.log("deactivating non diffusible entreprise : ", siret) + console.info("deactivating non diffusible entreprise : ", siret) await Entreprise.deleteOne({ _id: entreprise._id }) await RoleManagement.deleteMany({ authorized_type: AccessEntityType.ENTREPRISE, authorized_id: entreprise._id.toString() }) } diff --git a/server/src/jobs/lbb/updateGeoLocations.ts b/server/src/jobs/lbb/updateGeoLocations.ts index e75e6555ec..71f602d0d8 100644 --- a/server/src/jobs/lbb/updateGeoLocations.ts +++ b/server/src/jobs/lbb/updateGeoLocations.ts @@ -62,7 +62,7 @@ const saveGeoData = async (geoData) => { try { await geoLocation.save() } catch (err) { - console.log("error saving geoloc probably from duplicate restriction: ", geoLocation.address) + console.error("error saving geoloc probably from duplicate restriction: ", geoLocation.address) } } } diff --git a/server/src/jobs/lbb/updateLbaCompanies.ts b/server/src/jobs/lbb/updateLbaCompanies.ts index a9e3c3e7bd..044f0617ef 100644 --- a/server/src/jobs/lbb/updateLbaCompanies.ts +++ b/server/src/jobs/lbb/updateLbaCompanies.ts @@ -87,7 +87,7 @@ export default async function updateLbaCompanies({ try { logMessage("info", " -- Start updating lbb db with new algo -- ") - console.log("UseAlgoFile : ", UseAlgoFile, " - ClearMongo : ", ClearMongo, " - UseSave : ", UseSave, " - ForceRecreate : ", ForceRecreate) + console.info("UseAlgoFile : ", UseAlgoFile, " - ClearMongo : ", ClearMongo, " - UseSave : ", UseSave, " - ForceRecreate : ", ForceRecreate) if (UseAlgoFile) { if (!ForceRecreate) { diff --git a/server/src/security/authorisationService.ts b/server/src/security/authorisationService.ts index 5469161de9..9f22df479d 100644 --- a/server/src/security/authorisationService.ts +++ b/server/src/security/authorisationService.ts @@ -13,6 +13,7 @@ import { getLastStatusEvent } from "shared/utils/getLastStatusEvent" import { Primitive } from "type-fest" import { Application, Cfa, Entreprise, Recruiter, User2 } from "@/common/model" +import { ObjectId } from "@/common/mongodb" import { getComputedUserAccess, getGrantedRoles } from "@/services/roleManagement.service" import { getUser2ByEmail } from "@/services/user2.service" import { isUserEmailChecked } from "@/services/userRecruteur.service" @@ -22,12 +23,14 @@ import { getUserFromRequest } from "./authenticationService" type RecruiterResource = { recruiter: IRecruiter } & ({ type: "ENTREPRISE"; entreprise: IEntreprise } | { type: "CFA"; cfa: ICFA }) type JobResource = { job: IJob; recruiterResource: RecruiterResource } type ApplicationResource = { application: IApplication; jobResource?: JobResource; applicantId?: string } +type EntrepriseResource = { entreprise: IEntreprise } type Resources = { users: Array<{ _id: string }> recruiters: Array jobs: Array applications: Array + entreprises: Array } // Specify what we need to simplify mocking in tests @@ -184,12 +187,41 @@ async function getApplicationResource(schema: S, r return results.flatMap((_) => (_ ? [_] : [])) } +async function getEntrepriseResource(schema: S, req: IRequest): Promise { + if (!schema.securityScheme.resources.entreprise) { + return [] + } + + const results: (EntrepriseResource | null)[] = await Promise.all( + schema.securityScheme.resources.entreprise.map(async (entrepriseDef): Promise => { + if ("siret" in entrepriseDef) { + const siret = getAccessResourcePathValue(entrepriseDef.siret, req) + const entreprise = await Entreprise.findOne({ siret }).lean() + return entreprise ? { entreprise } : null + } else if ("_id" in entrepriseDef) { + const id = getAccessResourcePathValue(entrepriseDef._id, req) + try { + new ObjectId(id) + } catch (e) { + return null + } + const entreprise = await Entreprise.findById(id).lean() + return entreprise ? { entreprise } : null + } + + assertUnreachable(entrepriseDef) + }) + ) + return results.flatMap((_) => (_ ? [_] : [])) +} + async function getResources(schema: S, req: IRequest): Promise { - const [recruiters, jobs, users, applications] = await Promise.all([ + const [recruiters, jobs, users, applications, entreprises] = await Promise.all([ getRecruitersResource(schema, req), getJobsResource(schema, req), getUserResource(schema, req), getApplicationResource(schema, req), + getEntrepriseResource(schema, req), ]) return { @@ -197,6 +229,7 @@ async function getResources(schema: S, req: IReque jobs, users, applications, + entreprises, } } @@ -230,6 +263,12 @@ function canAccessApplication(userAccess: ComputedUserAccess, resource: Applicat return (jobResource && canAccessJob(userAccess, jobResource)) || (applicantId ? canAccessUser(userAccess, { _id: applicantId }) : false) } +function canAccessEntreprise(userAccess: ComputedUserAccess, resource: EntrepriseResource): boolean { + const { entreprise } = resource + const entrepriseOpco = parseEnum(OPCOS, entreprise.opco) + return userAccess.entreprises.includes(entreprise._id.toString()) || Boolean(entrepriseOpco && userAccess.opcos.includes(entrepriseOpco)) +} + function isAuthorized(access: AccessPermission, userAccess: ComputedUserAccess, resources: Resources): boolean { if (typeof access === "object") { if ("some" in access) { @@ -244,7 +283,8 @@ function isAuthorized(access: AccessPermission, userAccess: ComputedUserAccess, resources.recruiters.every((recruiter) => canAccessRecruiter(userAccess, recruiter)) && resources.jobs.every((job) => canAccessJob(userAccess, job)) && resources.applications.every((application) => canAccessApplication(userAccess, application)) && - resources.users.every((user) => canAccessUser(userAccess, user)) + resources.users.every((user) => canAccessUser(userAccess, user)) && + resources.entreprises.every((entreprise) => canAccessEntreprise(userAccess, entreprise)) ) } diff --git a/server/src/services/user.service.ts b/server/src/services/user.service.ts index d8253984f5..e0e0c90b44 100644 --- a/server/src/services/user.service.ts +++ b/server/src/services/user.service.ts @@ -95,7 +95,7 @@ export const getUserAndRecruitersDataForOpcoUser = async ( if (!status) return acc const recruiter = recruiterMap.get(userRecruteur._id.toString()) const { establishment_id } = recruiter ?? {} - const { _id, first_name, last_name, establishment_raison_sociale, establishment_siret, createdAt, email, phone, type } = userRecruteur + const { _id, first_name, last_name, establishment_raison_sociale, establishment_siret, createdAt, email, phone, type, organizationId } = userRecruteur const userForOpco: IUserForOpco = { _id, first_name, @@ -109,6 +109,7 @@ export const getUserAndRecruitersDataForOpcoUser = async ( type, jobs_count: recruiter?.jobs?.length ?? 0, origin: recruiter?.origin ?? "", + organizationId, } if (status === ETAT_UTILISATEUR.ATTENTE) { acc.awaiting.push(userForOpco) diff --git a/server/src/services/userRecruteur.service.ts b/server/src/services/userRecruteur.service.ts index 997d7adeb9..2e85b39bf1 100644 --- a/server/src/services/userRecruteur.service.ts +++ b/server/src/services/userRecruteur.service.ts @@ -474,6 +474,7 @@ export const getUserRecruteursForManagement = async ({ opco, activeRoleLimit }: origin, opco, status, + organizationId: organization._id, } return userRecruteurForAdmin }) diff --git a/shared/models/usersRecruteur.model.ts b/shared/models/usersRecruteur.model.ts index e37498af56..55614715e6 100644 --- a/shared/models/usersRecruteur.model.ts +++ b/shared/models/usersRecruteur.model.ts @@ -188,6 +188,8 @@ export const UserRecruteurForAdminProjection = { status: true, } as const -export const ZUserRecruteurForAdmin = ZUserRecruteur.pick(UserRecruteurForAdminProjection) +export const ZUserRecruteurForAdmin = ZUserRecruteur.pick(UserRecruteurForAdminProjection).extend({ + organizationId: zObjectId, +}) export type IUserRecruteurForAdmin = z.output diff --git a/shared/routes/user.routes.ts b/shared/routes/user.routes.ts index 7ff577b651..5ae3fdfcb9 100644 --- a/shared/routes/user.routes.ts +++ b/shared/routes/user.routes.ts @@ -23,6 +23,7 @@ const ZUserForOpco = ZUserRecruteur.pick({ }).extend({ jobs_count: z.number(), origin: z.string(), + organizationId: zObjectId, }) export type IUserForOpco = z.output @@ -132,6 +133,7 @@ export const zUserRecruteurRoutes = { _id: { type: "params", key: "userId" }, }, ], + entreprise: [{ _id: { type: "params", key: "organizationId" } }], }, }, }, @@ -244,8 +246,11 @@ export const zUserRecruteurRoutes = { }, securityScheme: { auth: "cookie-session", - access: "admin", - resources: {}, + access: "user:manage", + resources: { + user: [{ _id: { type: "params", key: "userId" } }], + entreprise: [{ siret: { type: "params", key: "siret" } }], + }, }, }, "/user/:userId/organization/:organizationId/permission": { diff --git a/shared/security/permissions.ts b/shared/security/permissions.ts index 814ebc5a7c..20aec0eb51 100644 --- a/shared/security/permissions.ts +++ b/shared/security/permissions.ts @@ -73,6 +73,14 @@ export type AccessRessouces = { user?: ReadonlyArray<{ _id: AccessResourcePath }> + entreprise?: ReadonlyArray< + | { + _id: AccessResourcePath + } + | { + siret: AccessResourcePath + } + > } export type UserWithType = Readonly<{ diff --git a/shared/utils/enumUtils.ts b/shared/utils/enumUtils.ts index c5c2fa24bd..e9e9a1f724 100644 --- a/shared/utils/enumUtils.ts +++ b/shared/utils/enumUtils.ts @@ -1,4 +1,4 @@ -export const parseEnum = (enumObj: Record, value: string | null): T | null => { +export const parseEnum = (enumObj: Record, value: string | null | undefined): T | null => { return Object.values(enumObj).find((enumValue) => enumValue.toLowerCase() === value?.toLowerCase()) ?? null } export const isEnum = (enumValues: Record, value: unknown): value is T => typeof value === "string" && parseEnum(enumValues, value) !== null diff --git a/ui/pages/espace-pro/administration/opco/entreprise/[siret_userId]/index.tsx b/ui/pages/espace-pro/administration/opco/entreprise/[siret_userId]/entreprise/[entreprise_id].tsx similarity index 95% rename from ui/pages/espace-pro/administration/opco/entreprise/[siret_userId]/index.tsx rename to ui/pages/espace-pro/administration/opco/entreprise/[siret_userId]/entreprise/[entreprise_id].tsx index c67a9ceef1..be51611e2d 100644 --- a/ui/pages/espace-pro/administration/opco/entreprise/[siret_userId]/index.tsx +++ b/ui/pages/espace-pro/administration/opco/entreprise/[siret_userId]/entreprise/[entreprise_id].tsx @@ -25,11 +25,9 @@ import { IUserStatusValidation } from "shared" import { ETAT_UTILISATEUR } from "shared/constants/recruteur" import * as Yup from "yup" +import { AUTHTYPE } from "@/common/contants" import { useUserPermissionsActions } from "@/common/hooks/useUserPermissionsActions" import { getAuthServerSideProps } from "@/common/SSR/getAuthServerSideProps" -import { useAuth } from "@/context/UserContext" - -import { AUTHTYPE } from "../../../../../../common/contants" import { AnimationContainer, ConfirmationDesactivationUtilisateur, @@ -39,11 +37,12 @@ import { Layout, LoadingEmptySpace, UserValidationHistory, -} from "../../../../../../components/espace_pro" -import { OpcoSelect } from "../../../../../../components/espace_pro/CreationRecruteur/OpcoSelect" -import { authProvider, withAuth } from "../../../../../../components/espace_pro/withAuth" -import { ArrowDropRightLine, ArrowRightLine } from "../../../../../../theme/components/icons" -import { getUser, updateEntrepriseAdmin } from "../../../../../../utils/api" +} from "@/components/espace_pro" +import { OpcoSelect } from "@/components/espace_pro/CreationRecruteur/OpcoSelect" +import { authProvider, withAuth } from "@/components/espace_pro/withAuth" +import { useAuth } from "@/context/UserContext" +import { ArrowDropRightLine, ArrowRightLine } from "@/theme/components/icons" +import { getUser, updateEntrepriseAdmin } from "@/utils/api" function DetailEntreprise() { const confirmationDesactivationUtilisateur = useDisclosure() @@ -52,11 +51,11 @@ function DetailEntreprise() { const toast = useToast() const { user } = useAuth() const router = useRouter() - const { siret_userId } = router.query as { siret_userId: string } + const { siret_userId, entreprise_id } = router.query as { siret_userId: string; entreprise_id: string } const { data: userRecruteur, isLoading } = useQuery("user", { enabled: !!siret_userId, - queryFn: () => getUser(siret_userId), + queryFn: () => getUser(siret_userId, entreprise_id), cacheTime: 0, }) diff --git a/ui/pages/espace-pro/administration/opco/index.tsx b/ui/pages/espace-pro/administration/opco/index.tsx index d1b8820893..695c1ff4d0 100644 --- a/ui/pages/espace-pro/administration/opco/index.tsx +++ b/ui/pages/espace-pro/administration/opco/index.tsx @@ -73,10 +73,10 @@ function AdministrationOpco() { row: { id }, }, }) => { - const { establishment_raison_sociale, establishment_siret, _id } = data[id] + const { establishment_raison_sociale, establishment_siret, _id, organizationId } = data[id] return ( - + {establishment_raison_sociale} @@ -162,7 +162,7 @@ function AdministrationOpco() { - Voir les informations + Voir les informations Voir les offres From e562165fc990ea78f20a62d4eeebacb1a50c2dc9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9my=20Auricoste?= Date: Tue, 2 Apr 2024 17:34:53 +0200 Subject: [PATCH 73/78] fix: adaptation des access logs --- server/src/security/accessLog.service.ts | 3 +-- server/src/security/accessLog.types.ts | 3 --- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/server/src/security/accessLog.service.ts b/server/src/security/accessLog.service.ts index a908dfd6b8..113435db80 100644 --- a/server/src/security/accessLog.service.ts +++ b/server/src/security/accessLog.service.ts @@ -25,13 +25,12 @@ export const createAccessLog = async Date: Wed, 3 Apr 2024 10:08:46 +0200 Subject: [PATCH 74/78] fix: search --- server/src/services/lbajob.service.ts | 34 +++++++++++++-------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/server/src/services/lbajob.service.ts b/server/src/services/lbajob.service.ts index af49a32e6a..b327da8546 100644 --- a/server/src/services/lbajob.service.ts +++ b/server/src/services/lbajob.service.ts @@ -71,32 +71,32 @@ export const getJobs = async ({ distance, lat, lon, romes, niveau }: { distance: const recruiters: IRecruiter[] = await Recruiter.aggregate(stages) const filteredJobs = await Promise.all( - recruiters.map(async (job) => { + recruiters.map(async (recruiter) => { const jobs: any[] = [] - if (job.is_delegated && job.cfa_delegated_siret) { - const cfa = await Cfa.findOne({ siret: job.cfa_delegated_siret }) - const cfaUser = await getUser2ManagingOffer(jobs[0]) + if (recruiter.is_delegated && recruiter.cfa_delegated_siret) { + const cfa = await Cfa.findOne({ siret: recruiter.cfa_delegated_siret }) + const cfaUser = await getUser2ManagingOffer(recruiter.jobs[0]) - job.phone = cfaUser.phone - job.email = cfaUser.email - job.last_name = cfaUser.last_name - job.first_name = cfaUser.first_name - job.establishment_raison_sociale = cfa?.raison_sociale - job.address = cfa?.address + recruiter.phone = cfaUser.phone + recruiter.email = cfaUser.email + recruiter.last_name = cfaUser.last_name + recruiter.first_name = cfaUser.first_name + recruiter.establishment_raison_sociale = cfa?.raison_sociale + recruiter.address = cfa?.address } - job.jobs.forEach((o) => { - if (romes.some((item) => o.rome_code.includes(item)) && o.job_status === JOB_STATUS.ACTIVE) { - o.rome_label = o.rome_appellation_label ?? o.rome_label - if (!niveau || NIVEAUX_POUR_LBA["INDIFFERENT"] === o.job_level_label || niveau === o.job_level_label) { - jobs.push(o) + recruiter.jobs.forEach((job) => { + if (romes.some((item) => job.rome_code.includes(item)) && job.job_status === JOB_STATUS.ACTIVE) { + job.rome_label = job.rome_appellation_label ?? job.rome_label + if (!niveau || NIVEAUX_POUR_LBA["INDIFFERENT"] === job.job_level_label || niveau === job.job_level_label) { + jobs.push(job) } } }) - job.jobs = jobs - return job + recruiter.jobs = jobs + return recruiter }) ) From 9d12dcf870cc05a76322c681e0a571ff6fe3e988 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9my=20Auricoste?= Date: Thu, 4 Apr 2024 14:47:01 +0200 Subject: [PATCH 75/78] fix: amelioration label des tests --- server/tests/unit/security/authorisationService.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/server/tests/unit/security/authorisationService.test.ts b/server/tests/unit/security/authorisationService.test.ts index d6ce3acd40..239c874e2e 100644 --- a/server/tests/unit/security/authorisationService.test.ts +++ b/server/tests/unit/security/authorisationService.test.ts @@ -108,7 +108,7 @@ describe("authorisationService", async () => { }) }) describe.each(everyResourceType)("given an accessed resource of type %s", (resourceType) => { - it("should always allow a token user", async () => { + it("should always allow a token user because authorization has been dealt with in the authentication layer", async () => { await expect( authorizationMiddleware(givenARoute({ authStrategy: "access-token", resourceType }), givenARequest({ user: givenATokenUser(), resourceId: "resourceId" })) ).resolves.toBe(undefined) @@ -169,7 +169,7 @@ describe("authorisationService", async () => { ) ).resolves.toBe(undefined) }) - it("an entreprise user should NOT have access to another entreprise jobs", async () => { + it("an entreprise user should NOT have access to another entreprise's jobs", async () => { const user = entrepriseUserA.user const { recruiter } = entrepriseUserB const [job] = recruiter.jobs @@ -190,7 +190,7 @@ describe("authorisationService", async () => { ) ).resolves.toBe(undefined) }) - it("a cfa user should NOT have access to another cfa job", async () => { + it("a cfa user should NOT have access to another cfa's job", async () => { const user = cfaUserA.user const { recruiter } = cfaUserB const [job] = recruiter.jobs From f5fd07f25002f7d5ae3e4f9abf45ee138323cf96 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9my=20Auricoste?= Date: Wed, 24 Apr 2024 11:13:49 +0200 Subject: [PATCH 76/78] fix: script de reprise siret: pas de reprise des roles au status DENIED --- .../jobs/lba_recruteur/user/misc/updateSiretInfosInError.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/server/src/jobs/lba_recruteur/user/misc/updateSiretInfosInError.ts b/server/src/jobs/lba_recruteur/user/misc/updateSiretInfosInError.ts index ac4ba78cbe..eca4ae41ee 100644 --- a/server/src/jobs/lba_recruteur/user/misc/updateSiretInfosInError.ts +++ b/server/src/jobs/lba_recruteur/user/misc/updateSiretInfosInError.ts @@ -43,7 +43,8 @@ const updateEntreprisesInfosInError = async () => { await Recruiter.updateMany({ establishment_siret: siret }, entrepriseData) const recruiters = await Recruiter.find({ establishment_siret: siret }).lean() const roles = await RoleManagement.find({ authorized_type: AccessEntityType.ENTREPRISE, authorized_id: updatedEntreprise._id.toString() }).lean() - const users = await User2.find({ _id: { $in: roles.map((role) => role.user_id) } }).lean() + const rolesToUpdate = roles.filter((role) => getLastStatusEvent(role.status)?.status !== AccessStatus.DENIED) + const users = await User2.find({ _id: { $in: rolesToUpdate.map((role) => role.user_id) } }).lean() await Promise.all( users.map(async (user) => { const userAndOrganization: UserAndOrganization = { user, type: ENTREPRISE, organization: updatedEntreprise } @@ -54,7 +55,7 @@ const updateEntreprisesInfosInError = async () => { throw Boom.internal(`inattendu : recruiter non trouvé`, { email: user.email, siret }) } await activateEntrepriseRecruiterForTheFirstTime(recruiter) - const role = roles.find((role) => role.user_id.toString() === user._id.toString()) + const role = rolesToUpdate.find((role) => role.user_id.toString() === user._id.toString()) const status = getLastStatusEvent(role?.status)?.status if (!status) { throw Boom.internal("inattendu : status du role non trouvé") From c86a615c35f4ea0afffc8df8b2fe56068de859ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9my=20Auricoste?= Date: Wed, 24 Apr 2024 11:22:33 +0200 Subject: [PATCH 77/78] fix: script reprise entreprise: gestion du status A_METTRE_A_JOUR --- .../src/jobs/lba_recruteur/user/misc/updateSiretInfosInError.ts | 2 +- shared/models/entreprise.model.ts | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/server/src/jobs/lba_recruteur/user/misc/updateSiretInfosInError.ts b/server/src/jobs/lba_recruteur/user/misc/updateSiretInfosInError.ts index eca4ae41ee..30fac87924 100644 --- a/server/src/jobs/lba_recruteur/user/misc/updateSiretInfosInError.ts +++ b/server/src/jobs/lba_recruteur/user/misc/updateSiretInfosInError.ts @@ -19,7 +19,7 @@ import { UserAndOrganization, deactivateEntreprise, setEntrepriseInError } from const updateEntreprisesInfosInError = async () => { const entreprises = await Entreprise.find({ - $expr: { $eq: [{ $arrayElemAt: ["$status.status", -1] }, EntrepriseStatus.ERROR] }, + $expr: { $in: [{ $arrayElemAt: ["$status.status", -1] }, [EntrepriseStatus.ERROR, EntrepriseStatus.A_METTRE_A_JOUR]] }, }).lean() const stats = { success: 0, failure: 0, deactivated: 0 } logger.info(`Correction des entreprises en erreur: ${entreprises.length} entreprises à mettre à jour...`) diff --git a/shared/models/entreprise.model.ts b/shared/models/entreprise.model.ts index c16f9496ff..81afe3d1cd 100644 --- a/shared/models/entreprise.model.ts +++ b/shared/models/entreprise.model.ts @@ -11,6 +11,7 @@ export enum EntrepriseStatus { ERROR = "ERROR", VALIDE = "VALIDE", DESACTIVE = "DESACTIVE", + A_METTRE_A_JOUR = "A_METTRE_A_JOUR", } export const ZEntrepriseStatusEvent = z From 8903cbe8db0820b1c2f6c9f4c44901811ac076bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9my=20Auricoste?= Date: Wed, 24 Apr 2024 18:13:34 +0200 Subject: [PATCH 78/78] fix: typing --- server/src/jobs/lba_recruteur/formulaire/relanceFormulaire.ts | 3 +++ server/src/services/roleManagement.service.ts | 3 +-- server/src/services/user.service.ts | 4 ++++ server/src/services/userRecruteur.service.ts | 3 +-- shared/constants/recruteur.ts | 1 + 5 files changed, 10 insertions(+), 4 deletions(-) diff --git a/server/src/jobs/lba_recruteur/formulaire/relanceFormulaire.ts b/server/src/jobs/lba_recruteur/formulaire/relanceFormulaire.ts index 0408750c25..1e85b4c7d2 100644 --- a/server/src/jobs/lba_recruteur/formulaire/relanceFormulaire.ts +++ b/server/src/jobs/lba_recruteur/formulaire/relanceFormulaire.ts @@ -51,6 +51,9 @@ export const relanceFormulaire = async (threshold: number /* number of days to e const { establishment_raison_sociale, is_delegated } = recruiter try { const { managed_by } = recruiter.jobs[0] + if (!managed_by) { + throw Boom.internal(`inattendu : managed_by manquant pour le formulaire id=${recruiter._id}`) + } const contactUser = await User2.findOne({ _id: managed_by }).lean() if (!contactUser) { throw Boom.internal(`inattendu : impossible de trouver l'utilisateur gérant le formulaire id=${recruiter._id}`) diff --git a/server/src/services/roleManagement.service.ts b/server/src/services/roleManagement.service.ts index a30d9b0819..653ffb8dee 100644 --- a/server/src/services/roleManagement.service.ts +++ b/server/src/services/roleManagement.service.ts @@ -1,6 +1,6 @@ import Boom from "boom" import type { ObjectId } from "mongodb" -import { ETAT_UTILISATEUR, OPCOS } from "shared/constants/recruteur" +import { ADMIN, CFA, ENTREPRISE, ETAT_UTILISATEUR, OPCO, OPCOS } from "shared/constants/recruteur" import { ComputedUserAccess, IUserRecruteurPublic } from "shared/models" import { AccessEntityType, AccessStatus, IRoleManagement, IRoleManagementEvent } from "shared/models/roleManagement.model" import { parseEnum, parseEnumOrError } from "shared/utils" @@ -8,7 +8,6 @@ import { getLastStatusEvent } from "shared/utils/getLastStatusEvent" import { Cfa, Entreprise, RoleManagement, User2 } from "@/common/model" -import { ADMIN, CFA, ENTREPRISE, OPCO } from "./constant.service" import { getFormulaireFromUserIdOrError } from "./formulaire.service" export const modifyPermissionToUser = async ( diff --git a/server/src/services/user.service.ts b/server/src/services/user.service.ts index e0e0c90b44..11922b202b 100644 --- a/server/src/services/user.service.ts +++ b/server/src/services/user.service.ts @@ -1,3 +1,4 @@ +import Boom from "boom" import type { FilterQuery } from "mongoose" import { IUser } from "shared" import { ETAT_UTILISATEUR, OPCOS } from "shared/constants/recruteur" @@ -85,6 +86,9 @@ export const getUserAndRecruitersDataForOpcoUser = async ( const recruiterMap = new Map() recruiters.forEach((recruiter) => { recruiter.jobs.forEach((job) => { + if (!job.managed_by) { + throw Boom.internal(`inattendu: managed_by vide pour le job avec id=${job._id}`) + } recruiterMap.set(job.managed_by.toString(), recruiter) }) }) diff --git a/server/src/services/userRecruteur.service.ts b/server/src/services/userRecruteur.service.ts index 2e85b39bf1..cc6c9a3e04 100644 --- a/server/src/services/userRecruteur.service.ts +++ b/server/src/services/userRecruteur.service.ts @@ -3,7 +3,7 @@ import { randomUUID } from "crypto" import Boom from "boom" import { IRecruiter, IUserRecruteur, IUserRecruteurForAdmin, IUserStatusValidation, assertUnreachable, parseEnumOrError, removeUndefinedFields } from "shared" import { BusinessErrorCodes } from "shared/constants/errorCodes" -import { CFA, ENTREPRISE, ETAT_UTILISATEUR, OPCOS, VALIDATION_UTILISATEUR } from "shared/constants/recruteur" +import { ADMIN, CFA, ENTREPRISE, ETAT_UTILISATEUR, OPCO, OPCOS, VALIDATION_UTILISATEUR } from "shared/constants/recruteur" import { ICFA } from "shared/models/cfa.model" import { EntrepriseStatus, IEntreprise, IEntrepriseStatusEvent } from "shared/models/entreprise.model" import { AccessEntityType, AccessStatus, IRoleManagement, IRoleManagementEvent } from "shared/models/roleManagement.model" @@ -18,7 +18,6 @@ import { Cfa, Entreprise, Recruiter, RoleManagement, User2 } from "../common/mod import config from "../config" import { createAuthMagicLink } from "./appLinks.service" -import { ADMIN, OPCO } from "./constant.service" import { getFormulaireFromUserIdOrError } from "./formulaire.service" import mailer, { sanitizeForEmail } from "./mailer.service" import { createOrganizationIfNotExist } from "./organization.service" diff --git a/shared/constants/recruteur.ts b/shared/constants/recruteur.ts index 839d2c4283..5e033fb82f 100644 --- a/shared/constants/recruteur.ts +++ b/shared/constants/recruteur.ts @@ -30,6 +30,7 @@ export enum ETAT_UTILISATEUR { export const ENTREPRISE = "ENTREPRISE" export const CFA = "CFA" export const ADMIN = "ADMIN" +export const OPCO = "OPCO" export const AUTHTYPE = { OPCO: "OPCO",