@programisto/edrm-exams 0.3.15 → 0.3.17
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/modules/edrm-exams/listeners/invite-from-job.listener.js +7 -0
- package/dist/modules/edrm-exams/models/candidate.model.d.ts +1 -0
- package/dist/modules/edrm-exams/models/candidate.model.js +5 -0
- package/dist/modules/edrm-exams/routes/exams-candidate.router.js +33 -8
- package/dist/modules/edrm-exams/routes/exams.router.js +41 -13
- package/package.json +1 -1
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { Types } from 'mongoose';
|
|
1
2
|
import { enduranceListener, enduranceEmitter, enduranceEventTypes } from '@programisto/endurance';
|
|
2
3
|
import Test from '../models/test.model.js';
|
|
3
4
|
import TestResult from '../models/test-result.model.js';
|
|
@@ -53,12 +54,18 @@ async function inviteCandidateToTest(payload) {
|
|
|
53
54
|
const testLink = (process.env.TEST_INVITATION_LINK || '') + email;
|
|
54
55
|
const emailUser = process.env.EMAIL_USER;
|
|
55
56
|
const emailPassword = process.env.EMAIL_PASSWORD;
|
|
57
|
+
const entityIdForMail = payload.entityId != null
|
|
58
|
+
? (payload.entityId instanceof Types.ObjectId
|
|
59
|
+
? payload.entityId
|
|
60
|
+
: new Types.ObjectId(String(payload.entityId)))
|
|
61
|
+
: undefined;
|
|
56
62
|
await enduranceEmitter.emit(enduranceEventTypes.SEND_EMAIL, {
|
|
57
63
|
template: 'test-invitation',
|
|
58
64
|
to: email,
|
|
59
65
|
from: emailUser,
|
|
60
66
|
emailUser,
|
|
61
67
|
emailPassword,
|
|
68
|
+
...(entityIdForMail && { entityId: entityIdForMail }),
|
|
62
69
|
data: {
|
|
63
70
|
firstname: contact.firstname,
|
|
64
71
|
testName: test?.title || '',
|
|
@@ -20,6 +20,7 @@ export var ExperienceLevel;
|
|
|
20
20
|
})(ExperienceLevel || (ExperienceLevel = {}));
|
|
21
21
|
/* eslint-enable no-unused-vars */
|
|
22
22
|
let Candidate = class Candidate extends EnduranceSchema {
|
|
23
|
+
entityId;
|
|
23
24
|
contact;
|
|
24
25
|
experienceLevel;
|
|
25
26
|
yearsOfExperience;
|
|
@@ -32,6 +33,10 @@ let Candidate = class Candidate extends EnduranceSchema {
|
|
|
32
33
|
return CandidateModel;
|
|
33
34
|
}
|
|
34
35
|
};
|
|
36
|
+
__decorate([
|
|
37
|
+
EnduranceModelType.prop({ type: Types.ObjectId, ref: 'Entity', required: false }),
|
|
38
|
+
__metadata("design:type", Types.ObjectId)
|
|
39
|
+
], Candidate.prototype, "entityId", void 0);
|
|
35
40
|
__decorate([
|
|
36
41
|
EnduranceModelType.prop({ required: true, ref: 'Contact' }),
|
|
37
42
|
__metadata("design:type", Types.ObjectId)
|
|
@@ -5,6 +5,7 @@ import TestResult from '../models/test-result.model.js';
|
|
|
5
5
|
import Test from '../models/test.model.js';
|
|
6
6
|
import TestJob from '../models/test-job.model.js';
|
|
7
7
|
import jwt from 'jsonwebtoken';
|
|
8
|
+
import { Types } from 'mongoose';
|
|
8
9
|
// Fonction utilitaire pour récupérer le nom du job
|
|
9
10
|
async function getJobName(targetJob) {
|
|
10
11
|
// Si c'est déjà une string (ancien format), on la retourne directement
|
|
@@ -23,6 +24,29 @@ async function getJobName(targetJob) {
|
|
|
23
24
|
}
|
|
24
25
|
return 'Job inconnu';
|
|
25
26
|
}
|
|
27
|
+
/** Entité par défaut : candidats sans entityId lui sont rattachés. */
|
|
28
|
+
function isDefaultEntity(req) {
|
|
29
|
+
if (!req?.entity)
|
|
30
|
+
return false;
|
|
31
|
+
const slug = req.entity.slug;
|
|
32
|
+
return req.entity.isDefault === true || slug === 'programisto' || slug === 'progamisto';
|
|
33
|
+
}
|
|
34
|
+
/** Filtre MongoDB pour les candidats de l'entité courante (évite les doublons quand un même contact existe sur plusieurs entités). */
|
|
35
|
+
function buildCandidateEntityFilter(req) {
|
|
36
|
+
if (!req?.entity?._id)
|
|
37
|
+
return {};
|
|
38
|
+
const eid = req.entity._id instanceof Types.ObjectId ? req.entity._id : new Types.ObjectId(String(req.entity._id));
|
|
39
|
+
if (isDefaultEntity(req)) {
|
|
40
|
+
return {
|
|
41
|
+
$or: [
|
|
42
|
+
{ entityId: eid },
|
|
43
|
+
{ entityId: null },
|
|
44
|
+
{ entityId: { $exists: false } }
|
|
45
|
+
]
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
return { entityId: eid };
|
|
49
|
+
}
|
|
26
50
|
class CandidateRouter extends EnduranceRouter {
|
|
27
51
|
constructor() {
|
|
28
52
|
super(EnduranceAuthMiddleware.getInstance());
|
|
@@ -204,6 +228,7 @@ class CandidateRouter extends EnduranceRouter {
|
|
|
204
228
|
const search = req.query.search || '';
|
|
205
229
|
const sortBy = req.query.sortBy || 'lastname';
|
|
206
230
|
const sortOrder = req.query.sortOrder || 'asc';
|
|
231
|
+
const entityFilter = buildCandidateEntityFilter(req);
|
|
207
232
|
let contactIds = [];
|
|
208
233
|
let total = 0;
|
|
209
234
|
if (search) {
|
|
@@ -217,27 +242,27 @@ class CandidateRouter extends EnduranceRouter {
|
|
|
217
242
|
};
|
|
218
243
|
const contacts = await ContactModel.find(contactQuery);
|
|
219
244
|
contactIds = contacts.map(contact => contact._id);
|
|
220
|
-
// Compter les candidats avec ces contacts
|
|
221
|
-
total = await CandidateModel.countDocuments({ contact: { $in: contactIds } });
|
|
245
|
+
// Compter les candidats avec ces contacts (scopé entité : éviter doublons multi-entités)
|
|
246
|
+
total = await CandidateModel.countDocuments({ contact: { $in: contactIds }, ...entityFilter });
|
|
222
247
|
}
|
|
223
248
|
else {
|
|
224
|
-
// Pas de recherche, compter tous les candidats
|
|
225
|
-
total = await CandidateModel.countDocuments();
|
|
249
|
+
// Pas de recherche, compter tous les candidats (scopé entité)
|
|
250
|
+
total = await CandidateModel.countDocuments(entityFilter);
|
|
226
251
|
}
|
|
227
252
|
// Construction du tri pour les contacts
|
|
228
253
|
const allowedSortFields = ['firstname', 'lastname', 'email'];
|
|
229
254
|
const sortField = allowedSortFields.includes(sortBy) ? sortBy : 'lastname';
|
|
230
255
|
let candidates;
|
|
231
256
|
if (search && contactIds.length > 0) {
|
|
232
|
-
// Récupérer les candidats avec les contacts trouvés
|
|
233
|
-
candidates = await CandidateModel.find({ contact: { $in: contactIds } })
|
|
257
|
+
// Récupérer les candidats avec les contacts trouvés (un seul par contact/entité)
|
|
258
|
+
candidates = await CandidateModel.find({ contact: { $in: contactIds }, ...entityFilter })
|
|
234
259
|
.skip(skip)
|
|
235
260
|
.limit(limit)
|
|
236
261
|
.exec();
|
|
237
262
|
}
|
|
238
263
|
else if (!search) {
|
|
239
|
-
// Récupérer
|
|
240
|
-
candidates = await CandidateModel.find()
|
|
264
|
+
// Récupérer les candidats de l'entité courante
|
|
265
|
+
candidates = await CandidateModel.find(entityFilter)
|
|
241
266
|
.skip(skip)
|
|
242
267
|
.limit(limit)
|
|
243
268
|
.exec();
|
|
@@ -8,6 +8,7 @@ import Candidate from '../models/candidate.model.js';
|
|
|
8
8
|
import ContactModel from '../models/contact.model.js';
|
|
9
9
|
import { generateLiveMessage } from '../lib/openai.js';
|
|
10
10
|
import { computeScoresByCategory } from '../lib/score-utils.js';
|
|
11
|
+
import { Types } from 'mongoose';
|
|
11
12
|
// Fonction utilitaire pour récupérer le nom du job
|
|
12
13
|
async function getJobName(targetJob) {
|
|
13
14
|
// Si c'est déjà une string (ancien format), on la retourne directement
|
|
@@ -1465,38 +1466,61 @@ class ExamsRouter extends EnduranceRouter {
|
|
|
1465
1466
|
if (!test) {
|
|
1466
1467
|
return res.status(404).json({ message: 'Test not found' });
|
|
1467
1468
|
}
|
|
1468
|
-
|
|
1469
|
-
const newResult = new TestResult({
|
|
1470
|
-
candidateId,
|
|
1471
|
-
testId,
|
|
1472
|
-
categories,
|
|
1473
|
-
state: 'pending',
|
|
1474
|
-
invitationDate: Date.now()
|
|
1475
|
-
});
|
|
1476
|
-
await newResult.save();
|
|
1477
|
-
// Récupérer l'email du candidat
|
|
1469
|
+
// Récupérer le candidat et le contact (avant de créer le TestResult pour pouvoir utiliser le candidat scopé entité)
|
|
1478
1470
|
const candidate = await Candidate.findById(candidateId);
|
|
1479
1471
|
if (!candidate) {
|
|
1480
1472
|
return res.status(404).json({ message: 'Candidate not found' });
|
|
1481
1473
|
}
|
|
1482
|
-
// Récupérer le contact pour obtenir l'email
|
|
1483
1474
|
const contact = await ContactModel.findById(candidate.contact);
|
|
1484
1475
|
if (!contact) {
|
|
1485
1476
|
return res.status(404).json({ message: 'Contact not found' });
|
|
1486
1477
|
}
|
|
1478
|
+
// Multi-entité : s'assurer qu'un Candidate existe pour l'entité courante (même contact).
|
|
1479
|
+
// Permet à un candidat déjà présent sur une autre entité de se connecter à l'espace candidat de cette entité.
|
|
1480
|
+
let entityCandidate = candidate;
|
|
1481
|
+
if (req.entity?._id) {
|
|
1482
|
+
const entityId = req.entity._id instanceof Types.ObjectId ? req.entity._id : new Types.ObjectId(String(req.entity._id));
|
|
1483
|
+
const existing = await Candidate.findOne({ contact: contact._id, entityId });
|
|
1484
|
+
if (!existing) {
|
|
1485
|
+
entityCandidate = new Candidate({
|
|
1486
|
+
contact: contact._id,
|
|
1487
|
+
entityId,
|
|
1488
|
+
skills: Array.isArray(candidate.skills) ? candidate.skills : [],
|
|
1489
|
+
experienceLevel: candidate.experienceLevel,
|
|
1490
|
+
yearsOfExperience: candidate.yearsOfExperience ?? 0
|
|
1491
|
+
});
|
|
1492
|
+
await entityCandidate.save();
|
|
1493
|
+
}
|
|
1494
|
+
else {
|
|
1495
|
+
entityCandidate = existing;
|
|
1496
|
+
}
|
|
1497
|
+
}
|
|
1498
|
+
const categories = test.categories.map((cat) => ({ categoryId: cat.categoryId }));
|
|
1499
|
+
const newResult = new TestResult({
|
|
1500
|
+
candidateId: entityCandidate._id,
|
|
1501
|
+
testId,
|
|
1502
|
+
categories,
|
|
1503
|
+
state: 'pending',
|
|
1504
|
+
invitationDate: Date.now()
|
|
1505
|
+
});
|
|
1506
|
+
await newResult.save();
|
|
1487
1507
|
const email = contact.email;
|
|
1488
1508
|
// Construire le lien d'invitation
|
|
1489
1509
|
const testLink = (process.env.TEST_INVITATION_LINK || '') + email;
|
|
1490
1510
|
// Récupérer les credentials d'envoi
|
|
1491
1511
|
const emailUser = process.env.EMAIL_USER;
|
|
1492
1512
|
const emailPassword = process.env.EMAIL_PASSWORD;
|
|
1493
|
-
// Envoyer l'email via l'event emitter
|
|
1513
|
+
// Envoyer l'email via l'event emitter (entityId pour utiliser le template de l'entité courante, ex. École de Turing)
|
|
1514
|
+
const entityIdForMail = req.entity?._id != null
|
|
1515
|
+
? (req.entity._id instanceof Types.ObjectId ? req.entity._id : new Types.ObjectId(String(req.entity._id)))
|
|
1516
|
+
: undefined;
|
|
1494
1517
|
await emitter.emit(eventTypes.SEND_EMAIL, {
|
|
1495
1518
|
template: 'test-invitation',
|
|
1496
1519
|
to: email,
|
|
1497
1520
|
from: emailUser,
|
|
1498
1521
|
emailUser,
|
|
1499
1522
|
emailPassword,
|
|
1523
|
+
...(entityIdForMail && { entityId: entityIdForMail }),
|
|
1500
1524
|
data: {
|
|
1501
1525
|
firstname: contact.firstname,
|
|
1502
1526
|
testName: test?.title || '',
|
|
@@ -2280,13 +2304,17 @@ class ExamsRouter extends EnduranceRouter {
|
|
|
2280
2304
|
const emailPassword = process.env.EMAIL_PASSWORD;
|
|
2281
2305
|
// Construire le lien d'invitation
|
|
2282
2306
|
const testLink = (process.env.TEST_INVITATION_LINK || '') + email;
|
|
2283
|
-
// Envoyer l'email via l'event emitter
|
|
2307
|
+
// Envoyer l'email via l'event emitter (entityId pour utiliser le template de l'entité courante)
|
|
2308
|
+
const entityIdForReinvite = req.entity?._id != null
|
|
2309
|
+
? (req.entity._id instanceof Types.ObjectId ? req.entity._id : new Types.ObjectId(String(req.entity._id)))
|
|
2310
|
+
: undefined;
|
|
2284
2311
|
await emitter.emit(eventTypes.SEND_EMAIL, {
|
|
2285
2312
|
template: 'test-invitation',
|
|
2286
2313
|
to: email,
|
|
2287
2314
|
from: emailUser,
|
|
2288
2315
|
emailUser,
|
|
2289
2316
|
emailPassword,
|
|
2317
|
+
...(entityIdForReinvite && { entityId: entityIdForReinvite }),
|
|
2290
2318
|
data: {
|
|
2291
2319
|
testLink
|
|
2292
2320
|
}
|