@programisto/edrm-exams 0.3.16 → 0.3.18
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/lib/test-invitation-link.d.ts +10 -0
- package/dist/modules/edrm-exams/lib/test-invitation-link.js +30 -0
- package/dist/modules/edrm-exams/listeners/invite-from-job.listener.js +9 -1
- package/dist/modules/edrm-exams/routes/exams-candidate.router.js +33 -8
- package/dist/modules/edrm-exams/routes/exams.router.js +19 -6
- package/package.json +1 -1
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Retourne la base du lien d'invitation au test technique (portail candidat).
|
|
3
|
+
* Utilise la config de l'entité (testInvitationLink) ou dérive depuis domain/domains.
|
|
4
|
+
* Fallback : process.env.TEST_INVITATION_LINK (ex. https://app.programisto.fr/magic?email=).
|
|
5
|
+
*/
|
|
6
|
+
export declare function getTestInvitationLinkBase(entity: {
|
|
7
|
+
config?: Record<string, string>;
|
|
8
|
+
domain?: string;
|
|
9
|
+
domains?: string[];
|
|
10
|
+
} | null | undefined): string;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Retourne la base du lien d'invitation au test technique (portail candidat).
|
|
3
|
+
* Utilise la config de l'entité (testInvitationLink) ou dérive depuis domain/domains.
|
|
4
|
+
* Fallback : process.env.TEST_INVITATION_LINK (ex. https://app.programisto.fr/magic?email=).
|
|
5
|
+
*/
|
|
6
|
+
export function getTestInvitationLinkBase(entity) {
|
|
7
|
+
if (!entity) {
|
|
8
|
+
return process.env.TEST_INVITATION_LINK || '';
|
|
9
|
+
}
|
|
10
|
+
const config = entity.config;
|
|
11
|
+
if (config && typeof config.testInvitationLink === 'string' && config.testInvitationLink.trim() !== '') {
|
|
12
|
+
const base = config.testInvitationLink.trim();
|
|
13
|
+
return base.includes('?') ? base : `${base}?email=`;
|
|
14
|
+
}
|
|
15
|
+
const domains = entity.domains;
|
|
16
|
+
if (Array.isArray(domains) && domains.length > 0) {
|
|
17
|
+
const appHost = domains.find((d) => String(d).toLowerCase().startsWith('app.'));
|
|
18
|
+
const host = appHost ?? domains[0];
|
|
19
|
+
const base = String(host).toLowerCase().split(':')[0].trim();
|
|
20
|
+
if (base)
|
|
21
|
+
return `https://${base}/magic?email=`;
|
|
22
|
+
}
|
|
23
|
+
const domain = entity.domain;
|
|
24
|
+
if (domain && String(domain).trim() !== '') {
|
|
25
|
+
const base = String(domain).trim().toLowerCase().replace(/^(my\.|jobs\.|api\.)/, '');
|
|
26
|
+
if (base)
|
|
27
|
+
return `https://app.${base}/magic?email=`;
|
|
28
|
+
}
|
|
29
|
+
return process.env.TEST_INVITATION_LINK || '';
|
|
30
|
+
}
|
|
@@ -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';
|
|
@@ -50,15 +51,22 @@ async function inviteCandidateToTest(payload) {
|
|
|
50
51
|
console.warn('[INVITE_TO_TECHNICAL_TEST] No email for contact');
|
|
51
52
|
return;
|
|
52
53
|
}
|
|
53
|
-
const
|
|
54
|
+
const linkBase = payload.testInvitationLinkBase ?? process.env.TEST_INVITATION_LINK ?? '';
|
|
55
|
+
const testLink = linkBase + email;
|
|
54
56
|
const emailUser = process.env.EMAIL_USER;
|
|
55
57
|
const emailPassword = process.env.EMAIL_PASSWORD;
|
|
58
|
+
const entityIdForMail = payload.entityId != null
|
|
59
|
+
? (payload.entityId instanceof Types.ObjectId
|
|
60
|
+
? payload.entityId
|
|
61
|
+
: new Types.ObjectId(String(payload.entityId)))
|
|
62
|
+
: undefined;
|
|
56
63
|
await enduranceEmitter.emit(enduranceEventTypes.SEND_EMAIL, {
|
|
57
64
|
template: 'test-invitation',
|
|
58
65
|
to: email,
|
|
59
66
|
from: emailUser,
|
|
60
67
|
emailUser,
|
|
61
68
|
emailPassword,
|
|
69
|
+
...(entityIdForMail && { entityId: entityIdForMail }),
|
|
62
70
|
data: {
|
|
63
71
|
firstname: contact.firstname,
|
|
64
72
|
testName: test?.title || '',
|
|
@@ -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 { getTestInvitationLinkBase } from '../lib/test-invitation-link.js';
|
|
11
12
|
import { Types } from 'mongoose';
|
|
12
13
|
// Fonction utilitaire pour récupérer le nom du job
|
|
13
14
|
async function getJobName(targetJob) {
|
|
@@ -1505,18 +1506,23 @@ class ExamsRouter extends EnduranceRouter {
|
|
|
1505
1506
|
});
|
|
1506
1507
|
await newResult.save();
|
|
1507
1508
|
const email = contact.email;
|
|
1508
|
-
// Construire le lien d'invitation
|
|
1509
|
-
const
|
|
1509
|
+
// Construire le lien d'invitation (URL de l'entité ou défaut env)
|
|
1510
|
+
const testLinkBase = getTestInvitationLinkBase(req.entity);
|
|
1511
|
+
const testLink = testLinkBase + email;
|
|
1510
1512
|
// Récupérer les credentials d'envoi
|
|
1511
1513
|
const emailUser = process.env.EMAIL_USER;
|
|
1512
1514
|
const emailPassword = process.env.EMAIL_PASSWORD;
|
|
1513
|
-
// Envoyer l'email via l'event emitter
|
|
1515
|
+
// Envoyer l'email via l'event emitter (entityId pour utiliser le template de l'entité courante, ex. École de Turing)
|
|
1516
|
+
const entityIdForMail = req.entity?._id != null
|
|
1517
|
+
? (req.entity._id instanceof Types.ObjectId ? req.entity._id : new Types.ObjectId(String(req.entity._id)))
|
|
1518
|
+
: undefined;
|
|
1514
1519
|
await emitter.emit(eventTypes.SEND_EMAIL, {
|
|
1515
1520
|
template: 'test-invitation',
|
|
1516
1521
|
to: email,
|
|
1517
1522
|
from: emailUser,
|
|
1518
1523
|
emailUser,
|
|
1519
1524
|
emailPassword,
|
|
1525
|
+
...(entityIdForMail && { entityId: entityIdForMail }),
|
|
1520
1526
|
data: {
|
|
1521
1527
|
firstname: contact.firstname,
|
|
1522
1528
|
testName: test?.title || '',
|
|
@@ -2298,16 +2304,23 @@ class ExamsRouter extends EnduranceRouter {
|
|
|
2298
2304
|
const email = contact.email;
|
|
2299
2305
|
const emailUser = process.env.EMAIL_USER;
|
|
2300
2306
|
const emailPassword = process.env.EMAIL_PASSWORD;
|
|
2301
|
-
// Construire le lien d'invitation
|
|
2302
|
-
const
|
|
2303
|
-
|
|
2307
|
+
// Construire le lien d'invitation (URL de l'entité ou défaut env)
|
|
2308
|
+
const testLinkBase = getTestInvitationLinkBase(req.entity);
|
|
2309
|
+
const testLink = testLinkBase + email;
|
|
2310
|
+
// Envoyer l'email via l'event emitter (entityId pour utiliser le template de l'entité courante)
|
|
2311
|
+
const entityIdForReinvite = req.entity?._id != null
|
|
2312
|
+
? (req.entity._id instanceof Types.ObjectId ? req.entity._id : new Types.ObjectId(String(req.entity._id)))
|
|
2313
|
+
: undefined;
|
|
2304
2314
|
await emitter.emit(eventTypes.SEND_EMAIL, {
|
|
2305
2315
|
template: 'test-invitation',
|
|
2306
2316
|
to: email,
|
|
2307
2317
|
from: emailUser,
|
|
2308
2318
|
emailUser,
|
|
2309
2319
|
emailPassword,
|
|
2320
|
+
...(entityIdForReinvite && { entityId: entityIdForReinvite }),
|
|
2310
2321
|
data: {
|
|
2322
|
+
firstname: contact.firstname,
|
|
2323
|
+
testName: test?.title || '',
|
|
2311
2324
|
testLink
|
|
2312
2325
|
}
|
|
2313
2326
|
});
|