@programisto/edrm-exams 0.3.11 → 0.3.13
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/score-utils.d.ts +18 -0
- package/dist/modules/edrm-exams/lib/score-utils.js +48 -0
- package/dist/modules/edrm-exams/listeners/correct.listener.js +5 -0
- package/dist/modules/edrm-exams/listeners/invite-from-job.listener.d.ts +2 -0
- package/dist/modules/edrm-exams/listeners/invite-from-job.listener.js +79 -0
- package/dist/modules/edrm-exams/models/test-question.model.d.ts +4 -2
- package/dist/modules/edrm-exams/models/test-question.model.js +6 -0
- package/dist/modules/edrm-exams/models/test-result.model.d.ts +6 -0
- package/dist/modules/edrm-exams/models/test-result.model.js +5 -0
- package/dist/modules/edrm-exams/routes/exams.router.js +29 -5
- package/package.json +1 -1
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { CategoryScore } from '../models/test-result.model.js';
|
|
2
|
+
export interface ComputeScoresResult {
|
|
3
|
+
score: number;
|
|
4
|
+
scoresByCategory: CategoryScore[];
|
|
5
|
+
}
|
|
6
|
+
/** Objet ayant au minimum testId et responses (document TestResult ou équivalent). */
|
|
7
|
+
export interface ResultWithResponses {
|
|
8
|
+
testId: unknown;
|
|
9
|
+
responses?: Array<{
|
|
10
|
+
questionId: unknown;
|
|
11
|
+
score?: number;
|
|
12
|
+
}>;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Calcule le score global et les sous-scores par catégorie à partir des réponses d'un TestResult.
|
|
16
|
+
* Les questions sans categoryId contribuent au score global uniquement (pas d'entrée dans scoresByCategory).
|
|
17
|
+
*/
|
|
18
|
+
export declare function computeScoresByCategory(result: ResultWithResponses): Promise<ComputeScoresResult>;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import Test from '../models/test.model.js';
|
|
2
|
+
import TestQuestion from '../models/test-question.model.js';
|
|
3
|
+
/**
|
|
4
|
+
* Calcule le score global et les sous-scores par catégorie à partir des réponses d'un TestResult.
|
|
5
|
+
* Les questions sans categoryId contribuent au score global uniquement (pas d'entrée dans scoresByCategory).
|
|
6
|
+
*/
|
|
7
|
+
export async function computeScoresByCategory(result) {
|
|
8
|
+
const responses = result.responses || [];
|
|
9
|
+
let score = 0;
|
|
10
|
+
const categoryMap = new Map();
|
|
11
|
+
const test = await Test.findById(result.testId).lean();
|
|
12
|
+
if (!test) {
|
|
13
|
+
return { score: 0, scoresByCategory: [] };
|
|
14
|
+
}
|
|
15
|
+
for (const res of responses) {
|
|
16
|
+
const questionId = res.questionId?.toString?.() ?? res.questionId;
|
|
17
|
+
if (!questionId)
|
|
18
|
+
continue;
|
|
19
|
+
const question = await TestQuestion.findById(questionId).lean();
|
|
20
|
+
if (!question)
|
|
21
|
+
continue;
|
|
22
|
+
const responseScore = typeof res.score === 'number' && !isNaN(res.score) ? res.score : 0;
|
|
23
|
+
const questionMaxScore = typeof question.maxScore === 'number' && !isNaN(question.maxScore) ? question.maxScore : 0;
|
|
24
|
+
score += responseScore;
|
|
25
|
+
const catId = question.categoryId;
|
|
26
|
+
if (catId) {
|
|
27
|
+
const key = catId.toString?.() ?? String(catId);
|
|
28
|
+
const existing = categoryMap.get(key);
|
|
29
|
+
if (existing) {
|
|
30
|
+
existing.score += responseScore;
|
|
31
|
+
existing.maxScore += questionMaxScore;
|
|
32
|
+
}
|
|
33
|
+
else {
|
|
34
|
+
categoryMap.set(key, {
|
|
35
|
+
categoryId: catId,
|
|
36
|
+
score: responseScore,
|
|
37
|
+
maxScore: questionMaxScore
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
const scoresByCategory = Array.from(categoryMap.values()).map(({ categoryId, score: catScore, maxScore: catMax }) => ({
|
|
43
|
+
categoryId: categoryId,
|
|
44
|
+
score: catScore,
|
|
45
|
+
maxScore: catMax
|
|
46
|
+
}));
|
|
47
|
+
return { score, scoresByCategory };
|
|
48
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { enduranceListener, enduranceEventTypes, enduranceEmitter } from '@programisto/endurance';
|
|
2
2
|
import TestQuestion from '../models/test-question.model.js';
|
|
3
3
|
import { generateLiveMessageAssistant } from '../lib/openai.js';
|
|
4
|
+
import { computeScoresByCategory } from '../lib/score-utils.js';
|
|
4
5
|
import TestResult, { TestState } from '../models/test-result.model.js';
|
|
5
6
|
import CandidateModel from '../models/candidate.model.js';
|
|
6
7
|
import ContactModel from '../models/contact.model.js';
|
|
@@ -97,6 +98,9 @@ async function correctTest(options) {
|
|
|
97
98
|
result.state = TestState.Finish;
|
|
98
99
|
// Forcer la sauvegarde des sous-documents responses
|
|
99
100
|
result.markModified('responses');
|
|
101
|
+
// Calculer les sous-scores par catégorie
|
|
102
|
+
const { scoresByCategory } = await computeScoresByCategory(result);
|
|
103
|
+
result.scoresByCategory = scoresByCategory;
|
|
100
104
|
// Calculer le pourcentage de score en évitant la division par zéro
|
|
101
105
|
let scorePercentage = 0;
|
|
102
106
|
if (maxScore > 0) {
|
|
@@ -115,6 +119,7 @@ async function correctTest(options) {
|
|
|
115
119
|
$set: {
|
|
116
120
|
responses: result.responses,
|
|
117
121
|
score: finalscore,
|
|
122
|
+
scoresByCategory: result.scoresByCategory,
|
|
118
123
|
state: result.state
|
|
119
124
|
}
|
|
120
125
|
});
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { enduranceListener, enduranceEmitter, enduranceEventTypes } from '@programisto/endurance';
|
|
2
|
+
import Test from '../models/test.model.js';
|
|
3
|
+
import TestResult from '../models/test-result.model.js';
|
|
4
|
+
import Candidate from '../models/candidate.model.js';
|
|
5
|
+
import ContactModel from '../models/contact.model.js';
|
|
6
|
+
const EVENT_INVITE_TO_TECHNICAL_TEST = 'INVITE_TO_TECHNICAL_TEST';
|
|
7
|
+
/**
|
|
8
|
+
* Même logique que POST /exams/invite : crée un TestResult et envoie l'email d'invitation.
|
|
9
|
+
* Déclenché quand une candidature est créée sur une offre avec un test lié (internal-portal).
|
|
10
|
+
*/
|
|
11
|
+
async function inviteCandidateToTest(payload) {
|
|
12
|
+
const candidateId = typeof payload.candidateId === 'object' && payload.candidateId?.toString
|
|
13
|
+
? payload.candidateId.toString()
|
|
14
|
+
: String(payload.candidateId ?? '');
|
|
15
|
+
const testId = typeof payload.testId === 'object' && payload.testId?.toString
|
|
16
|
+
? payload.testId.toString()
|
|
17
|
+
: String(payload.testId ?? '');
|
|
18
|
+
if (!candidateId || !testId)
|
|
19
|
+
return;
|
|
20
|
+
const existing = await TestResult.findOne({ candidateId, testId });
|
|
21
|
+
if (existing) {
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
const test = await Test.findById(testId);
|
|
25
|
+
if (!test) {
|
|
26
|
+
console.warn('[INVITE_TO_TECHNICAL_TEST] Test not found:', testId);
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
const categories = test.categories?.map((cat) => ({ categoryId: cat.categoryId })) ?? [];
|
|
30
|
+
const newResult = new TestResult({
|
|
31
|
+
candidateId,
|
|
32
|
+
testId,
|
|
33
|
+
categories,
|
|
34
|
+
state: 'pending',
|
|
35
|
+
invitationDate: Date.now()
|
|
36
|
+
});
|
|
37
|
+
await newResult.save();
|
|
38
|
+
const candidate = await Candidate.findById(candidateId);
|
|
39
|
+
if (!candidate) {
|
|
40
|
+
console.warn('[INVITE_TO_TECHNICAL_TEST] Candidate not found:', candidateId);
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
const contact = await ContactModel.findById(candidate.contact);
|
|
44
|
+
if (!contact) {
|
|
45
|
+
console.warn('[INVITE_TO_TECHNICAL_TEST] Contact not found for candidate:', candidateId);
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
const email = contact.email;
|
|
49
|
+
if (!email) {
|
|
50
|
+
console.warn('[INVITE_TO_TECHNICAL_TEST] No email for contact');
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
const testLink = (process.env.TEST_INVITATION_LINK || '') + email;
|
|
54
|
+
const emailUser = process.env.EMAIL_USER;
|
|
55
|
+
const emailPassword = process.env.EMAIL_PASSWORD;
|
|
56
|
+
await enduranceEmitter.emit(enduranceEventTypes.SEND_EMAIL, {
|
|
57
|
+
template: 'test-invitation',
|
|
58
|
+
to: email,
|
|
59
|
+
from: emailUser,
|
|
60
|
+
emailUser,
|
|
61
|
+
emailPassword,
|
|
62
|
+
data: {
|
|
63
|
+
firstname: contact.firstname,
|
|
64
|
+
testName: test?.title || '',
|
|
65
|
+
testLink
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
enduranceListener.createListener(EVENT_INVITE_TO_TECHNICAL_TEST, async (args) => {
|
|
70
|
+
try {
|
|
71
|
+
if (typeof args === 'object' && args !== null) {
|
|
72
|
+
await inviteCandidateToTest(args);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
catch (err) {
|
|
76
|
+
console.error('[INVITE_TO_TECHNICAL_TEST] Error inviting candidate to test:', err);
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
export default enduranceListener;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { EnduranceSchema } from '@programisto/endurance';
|
|
2
|
+
import TestCategory from './test-category.models.js';
|
|
2
3
|
declare enum QuestionType {
|
|
3
4
|
MCQ = "MCQ",
|
|
4
5
|
FreeQuestion = "free question",
|
|
@@ -19,7 +20,8 @@ declare class TestQuestion extends EnduranceSchema {
|
|
|
19
20
|
possibleResponses: PossibleResponse[];
|
|
20
21
|
time: number;
|
|
21
22
|
textType: TextType;
|
|
22
|
-
|
|
23
|
+
categoryId?: typeof TestCategory;
|
|
24
|
+
static getModel(): import("@typegoose/typegoose").ReturnModelType<typeof TestQuestion, import("@typegoose/typegoose/lib/types.js").BeAnObject>;
|
|
23
25
|
}
|
|
24
|
-
declare const TestQuestionModel: import("@typegoose/typegoose").ReturnModelType<typeof TestQuestion, import("@typegoose/typegoose/lib/types").BeAnObject>;
|
|
26
|
+
declare const TestQuestionModel: import("@typegoose/typegoose").ReturnModelType<typeof TestQuestion, import("@typegoose/typegoose/lib/types.js").BeAnObject>;
|
|
25
27
|
export default TestQuestionModel;
|
|
@@ -8,6 +8,7 @@ var __metadata = (this && this.__metadata) || function (k, v) {
|
|
|
8
8
|
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
|
|
9
9
|
};
|
|
10
10
|
import { EnduranceSchema, EnduranceModelType } from '@programisto/endurance';
|
|
11
|
+
import TestCategory from './test-category.models.js';
|
|
11
12
|
var QuestionType;
|
|
12
13
|
(function (QuestionType) {
|
|
13
14
|
// eslint-disable-next-line no-unused-vars
|
|
@@ -31,6 +32,7 @@ let TestQuestion = class TestQuestion extends EnduranceSchema {
|
|
|
31
32
|
possibleResponses;
|
|
32
33
|
time;
|
|
33
34
|
textType;
|
|
35
|
+
categoryId;
|
|
34
36
|
static getModel() {
|
|
35
37
|
return TestQuestionModel;
|
|
36
38
|
}
|
|
@@ -59,6 +61,10 @@ __decorate([
|
|
|
59
61
|
EnduranceModelType.prop({ required: false, enum: TextType, default: TextType.Text }),
|
|
60
62
|
__metadata("design:type", String)
|
|
61
63
|
], TestQuestion.prototype, "textType", void 0);
|
|
64
|
+
__decorate([
|
|
65
|
+
EnduranceModelType.prop({ ref: () => TestCategory, required: false }),
|
|
66
|
+
__metadata("design:type", Object)
|
|
67
|
+
], TestQuestion.prototype, "categoryId", void 0);
|
|
62
68
|
TestQuestion = __decorate([
|
|
63
69
|
EnduranceModelType.modelOptions({
|
|
64
70
|
options: {
|
|
@@ -12,12 +12,18 @@ interface Response {
|
|
|
12
12
|
score?: number;
|
|
13
13
|
comment?: string;
|
|
14
14
|
}
|
|
15
|
+
export interface CategoryScore {
|
|
16
|
+
categoryId: ObjectId;
|
|
17
|
+
score: number;
|
|
18
|
+
maxScore: number;
|
|
19
|
+
}
|
|
15
20
|
declare class TestResult extends EnduranceSchema {
|
|
16
21
|
testId: typeof Test;
|
|
17
22
|
candidateId: typeof User;
|
|
18
23
|
state: TestState;
|
|
19
24
|
responses: Response[];
|
|
20
25
|
score?: number;
|
|
26
|
+
scoresByCategory?: CategoryScore[];
|
|
21
27
|
startTime?: Date;
|
|
22
28
|
endTime?: Date;
|
|
23
29
|
static getModel(): import("@typegoose/typegoose").ReturnModelType<typeof TestResult, import("@typegoose/typegoose/lib/types.js").BeAnObject>;
|
|
@@ -25,6 +25,7 @@ let TestResult = class TestResult extends EnduranceSchema {
|
|
|
25
25
|
state;
|
|
26
26
|
responses;
|
|
27
27
|
score;
|
|
28
|
+
scoresByCategory;
|
|
28
29
|
startTime;
|
|
29
30
|
endTime;
|
|
30
31
|
static getModel() {
|
|
@@ -51,6 +52,10 @@ __decorate([
|
|
|
51
52
|
EnduranceModelType.prop(),
|
|
52
53
|
__metadata("design:type", Number)
|
|
53
54
|
], TestResult.prototype, "score", void 0);
|
|
55
|
+
__decorate([
|
|
56
|
+
EnduranceModelType.prop({ type: [Object], required: false }),
|
|
57
|
+
__metadata("design:type", Array)
|
|
58
|
+
], TestResult.prototype, "scoresByCategory", void 0);
|
|
54
59
|
__decorate([
|
|
55
60
|
EnduranceModelType.prop(),
|
|
56
61
|
__metadata("design:type", Date)
|
|
@@ -7,6 +7,7 @@ import TestJob from '../models/test-job.model.js';
|
|
|
7
7
|
import Candidate from '../models/candidate.model.js';
|
|
8
8
|
import ContactModel from '../models/contact.model.js';
|
|
9
9
|
import { generateLiveMessage, generateLiveMessageAssistant } from '../lib/openai.js';
|
|
10
|
+
import { computeScoresByCategory } from '../lib/score-utils.js';
|
|
10
11
|
// Fonction utilitaire pour récupérer le nom du job
|
|
11
12
|
async function getJobName(targetJob) {
|
|
12
13
|
// Si c'est déjà une string (ancien format), on la retourne directement
|
|
@@ -1092,7 +1093,7 @@ class ExamsRouter extends EnduranceRouter {
|
|
|
1092
1093
|
*/
|
|
1093
1094
|
this.put('/test/modifyQuestion/:id', authenticatedOptions, async (req, res) => {
|
|
1094
1095
|
const { id } = req.params;
|
|
1095
|
-
const { instruction, maxScore, time, possibleResponses, textType } = req.body;
|
|
1096
|
+
const { instruction, maxScore, time, possibleResponses, textType, categoryId } = req.body;
|
|
1096
1097
|
try {
|
|
1097
1098
|
const question = await TestQuestion.findById(id);
|
|
1098
1099
|
if (!question) {
|
|
@@ -1113,6 +1114,18 @@ class ExamsRouter extends EnduranceRouter {
|
|
|
1113
1114
|
if (possibleResponses) {
|
|
1114
1115
|
question.possibleResponses = possibleResponses;
|
|
1115
1116
|
}
|
|
1117
|
+
if (categoryId !== undefined) {
|
|
1118
|
+
if (categoryId === null || categoryId === '') {
|
|
1119
|
+
question.categoryId = undefined;
|
|
1120
|
+
}
|
|
1121
|
+
else {
|
|
1122
|
+
const category = await TestCategory.findById(categoryId);
|
|
1123
|
+
if (!category) {
|
|
1124
|
+
return res.status(400).json({ message: 'Catégorie non trouvée' });
|
|
1125
|
+
}
|
|
1126
|
+
question.categoryId = categoryId;
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
1116
1129
|
await question.save();
|
|
1117
1130
|
res.status(200).json({ message: 'question modified with sucess' });
|
|
1118
1131
|
}
|
|
@@ -1159,7 +1172,7 @@ class ExamsRouter extends EnduranceRouter {
|
|
|
1159
1172
|
*/
|
|
1160
1173
|
this.put('/test/addCustomQuestion/:id', authenticatedOptions, async (req, res) => {
|
|
1161
1174
|
const { id } = req.params;
|
|
1162
|
-
const { questionType, instruction, maxScore, time } = req.body;
|
|
1175
|
+
const { questionType, instruction, maxScore, time, categoryId } = req.body;
|
|
1163
1176
|
try {
|
|
1164
1177
|
const test = await Test.findById(id);
|
|
1165
1178
|
if (!test) {
|
|
@@ -1168,11 +1181,18 @@ class ExamsRouter extends EnduranceRouter {
|
|
|
1168
1181
|
if (!testBelongsToEntity(test, req)) {
|
|
1169
1182
|
return res.status(404).json({ message: 'no test founded with this id' });
|
|
1170
1183
|
}
|
|
1184
|
+
if (categoryId != null && categoryId !== '') {
|
|
1185
|
+
const category = await TestCategory.findById(categoryId);
|
|
1186
|
+
if (!category) {
|
|
1187
|
+
return res.status(400).json({ message: 'Catégorie non trouvée' });
|
|
1188
|
+
}
|
|
1189
|
+
}
|
|
1171
1190
|
const question = new TestQuestion({
|
|
1172
1191
|
questionType,
|
|
1173
1192
|
instruction,
|
|
1174
1193
|
maxScore,
|
|
1175
|
-
time
|
|
1194
|
+
time,
|
|
1195
|
+
...(categoryId != null && categoryId !== '' ? { categoryId } : {})
|
|
1176
1196
|
});
|
|
1177
1197
|
await question.save();
|
|
1178
1198
|
test.questions.push({ questionId: question._id, order: test.questions.length });
|
|
@@ -1792,6 +1812,8 @@ class ExamsRouter extends EnduranceRouter {
|
|
|
1792
1812
|
response.comment = parsedResult.comment;
|
|
1793
1813
|
}
|
|
1794
1814
|
result.score = finalscore;
|
|
1815
|
+
const { scoresByCategory } = await computeScoresByCategory(result);
|
|
1816
|
+
result.scoresByCategory = scoresByCategory;
|
|
1795
1817
|
await result.save();
|
|
1796
1818
|
res.status(200).json({ data: finalscore });
|
|
1797
1819
|
}
|
|
@@ -2350,8 +2372,10 @@ class ExamsRouter extends EnduranceRouter {
|
|
|
2350
2372
|
response.score = score;
|
|
2351
2373
|
if (typeof comment === 'string')
|
|
2352
2374
|
response.comment = comment;
|
|
2353
|
-
// Recalculer le score global
|
|
2354
|
-
|
|
2375
|
+
// Recalculer le score global et les sous-scores par catégorie
|
|
2376
|
+
const { score: globalScore, scoresByCategory } = await computeScoresByCategory(result);
|
|
2377
|
+
result.score = globalScore;
|
|
2378
|
+
result.scoresByCategory = scoresByCategory;
|
|
2355
2379
|
await result.save();
|
|
2356
2380
|
return res.status(200).json({
|
|
2357
2381
|
message: 'Correction manuelle enregistrée',
|