@programisto/edrm-exams 0.3.10 → 0.3.12

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.
@@ -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
  });
@@ -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
- static getModel(): import("@typegoose/typegoose").ReturnModelType<typeof TestQuestion, import("@typegoose/typegoose/lib/types").BeAnObject>;
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
@@ -25,6 +26,23 @@ async function getJobName(targetJob) {
25
26
  }
26
27
  return 'Job inconnu';
27
28
  }
29
+ /** Entité par défaut : les tests sans entityId lui sont rattachés (slug programisto/progamisto ou isDefault). */
30
+ function isDefaultEntity(req) {
31
+ if (!req?.entity)
32
+ return false;
33
+ const slug = req.entity.slug;
34
+ return req.entity.isDefault === true || slug === 'programisto' || slug === 'progamisto';
35
+ }
36
+ /** Vérifie qu'un test appartient à l'entité courante (pour GET/UPDATE/DELETE). Pour l'entité par défaut, un test sans entityId est accepté. */
37
+ function testBelongsToEntity(test, req) {
38
+ if (!req?.entity?._id)
39
+ return true;
40
+ const testEid = test?.entityId?.toString?.() ?? (test?.entityId != null ? String(test.entityId) : '');
41
+ const reqEid = req.entity._id?.toString?.() ?? String(req.entity._id);
42
+ if (isDefaultEntity(req))
43
+ return testEid === reqEid || testEid === '';
44
+ return testEid === reqEid;
45
+ }
28
46
  // Fonction pour migrer automatiquement un test si nécessaire
29
47
  async function migrateTestIfNeeded(test) {
30
48
  if (typeof test.targetJob === 'string') {
@@ -452,7 +470,7 @@ class ExamsRouter extends EnduranceRouter {
452
470
  if (!test) {
453
471
  return res.status(404).json({ message: 'Test non trouvé' });
454
472
  }
455
- if (req.entity?._id && test.entityId && !test.entityId.equals(req.entity._id)) {
473
+ if (!testBelongsToEntity(test, req)) {
456
474
  return res.status(404).json({ message: 'Test non trouvé' });
457
475
  }
458
476
  if (title)
@@ -543,7 +561,7 @@ class ExamsRouter extends EnduranceRouter {
543
561
  if (!test) {
544
562
  return res.status(404).json({ message: 'Test not found' });
545
563
  }
546
- if (req.entity?._id && test.entityId && !test.entityId.equals(req.entity._id)) {
564
+ if (!testBelongsToEntity(test, req)) {
547
565
  return res.status(404).json({ message: 'Test not found' });
548
566
  }
549
567
  for (let i = 0; i < test.questions.length; i++) {
@@ -587,7 +605,7 @@ class ExamsRouter extends EnduranceRouter {
587
605
  return res.status(404).json({ message: 'no test founded with this id' });
588
606
  }
589
607
  // Vérifier que le test appartient à l'entité courante (multi-entités)
590
- if (req.entity?._id && test.entityId && !test.entityId.equals(req.entity._id)) {
608
+ if (!testBelongsToEntity(test, req)) {
591
609
  return res.status(404).json({ message: 'no test founded with this id' });
592
610
  }
593
611
  // Migration automatique si nécessaire
@@ -677,9 +695,22 @@ class ExamsRouter extends EnduranceRouter {
677
695
  const sortOrder = req.query.sortOrder || 'desc';
678
696
  // Construction de la requête de recherche
679
697
  const query = {};
680
- // Filtre par entité (contexte multi-entités)
698
+ // Filtre par entité. Entité par défaut (programisto/progamisto ou isDefault) = tests sans entityId inclus.
681
699
  if (req.entity?._id) {
682
- query.entityId = req.entity._id;
700
+ if (isDefaultEntity(req)) {
701
+ query.$and = query.$and || [];
702
+ query.$and.push({
703
+ $or: [
704
+ { entityId: req.entity._id },
705
+ { entityId: req.entity._id.toString?.() ?? String(req.entity._id) },
706
+ { entityId: { $exists: false } },
707
+ { entityId: null }
708
+ ]
709
+ });
710
+ }
711
+ else {
712
+ query.entityId = req.entity._id;
713
+ }
683
714
  }
684
715
  // Filtres
685
716
  if (targetJob !== 'all') {
@@ -802,7 +833,7 @@ class ExamsRouter extends EnduranceRouter {
802
833
  let test = await Test.findById(testId);
803
834
  if (!test)
804
835
  return res.status(404).json({ message: 'Test not found' });
805
- if (req.entity?._id && test.entityId && !test.entityId.equals(req.entity._id)) {
836
+ if (!testBelongsToEntity(test, req)) {
806
837
  return res.status(404).json({ message: 'Test not found' });
807
838
  }
808
839
  test = await Test.findByIdAndUpdate(testId, { $pull: { categories: { categoryId: category._id } } }, { new: true });
@@ -858,7 +889,7 @@ class ExamsRouter extends EnduranceRouter {
858
889
  if (!test) {
859
890
  return res.status(404).json({ message: 'Test not found' });
860
891
  }
861
- if (req.entity?._id && test.entityId && !test.entityId.equals(req.entity._id)) {
892
+ if (!testBelongsToEntity(test, req)) {
862
893
  return res.status(404).json({ message: 'Test not found' });
863
894
  }
864
895
  const categoryExists = test.categories.some(cat => cat.categoryId.equals(category._id));
@@ -931,7 +962,7 @@ class ExamsRouter extends EnduranceRouter {
931
962
  if (!test) {
932
963
  return res.status(404).json({ message: 'Test not found' });
933
964
  }
934
- if (req.entity?._id && test.entityId && !test.entityId.equals(req.entity._id)) {
965
+ if (!testBelongsToEntity(test, req)) {
935
966
  return res.status(404).json({ message: 'Test not found' });
936
967
  }
937
968
  const questions = [];
@@ -984,7 +1015,7 @@ class ExamsRouter extends EnduranceRouter {
984
1015
  if (!test) {
985
1016
  return res.status(404).json({ message: 'no test founded with this id' });
986
1017
  }
987
- if (req.entity?._id && test.entityId && !test.entityId.equals(req.entity._id)) {
1018
+ if (!testBelongsToEntity(test, req)) {
988
1019
  return res.status(404).json({ message: 'no test founded with this id' });
989
1020
  }
990
1021
  // Supprimer la question du tableau questions en filtrant par questionId
@@ -1023,7 +1054,7 @@ class ExamsRouter extends EnduranceRouter {
1023
1054
  if (!test) {
1024
1055
  return res.status(404).json({ message: 'no test founded with this id' });
1025
1056
  }
1026
- if (req.entity?._id && test.entityId && !test.entityId.equals(req.entity._id)) {
1057
+ if (!testBelongsToEntity(test, req)) {
1027
1058
  return res.status(404).json({ message: 'no test founded with this id' });
1028
1059
  }
1029
1060
  for (const questionId of test.questions) {
@@ -1062,7 +1093,7 @@ class ExamsRouter extends EnduranceRouter {
1062
1093
  */
1063
1094
  this.put('/test/modifyQuestion/:id', authenticatedOptions, async (req, res) => {
1064
1095
  const { id } = req.params;
1065
- const { instruction, maxScore, time, possibleResponses, textType } = req.body;
1096
+ const { instruction, maxScore, time, possibleResponses, textType, categoryId } = req.body;
1066
1097
  try {
1067
1098
  const question = await TestQuestion.findById(id);
1068
1099
  if (!question) {
@@ -1083,6 +1114,18 @@ class ExamsRouter extends EnduranceRouter {
1083
1114
  if (possibleResponses) {
1084
1115
  question.possibleResponses = possibleResponses;
1085
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
+ }
1086
1129
  await question.save();
1087
1130
  res.status(200).json({ message: 'question modified with sucess' });
1088
1131
  }
@@ -1129,20 +1172,27 @@ class ExamsRouter extends EnduranceRouter {
1129
1172
  */
1130
1173
  this.put('/test/addCustomQuestion/:id', authenticatedOptions, async (req, res) => {
1131
1174
  const { id } = req.params;
1132
- const { questionType, instruction, maxScore, time } = req.body;
1175
+ const { questionType, instruction, maxScore, time, categoryId } = req.body;
1133
1176
  try {
1134
1177
  const test = await Test.findById(id);
1135
1178
  if (!test) {
1136
1179
  return res.status(404).json({ message: 'no test founded with this id' });
1137
1180
  }
1138
- if (req.entity?._id && test.entityId && !test.entityId.equals(req.entity._id)) {
1181
+ if (!testBelongsToEntity(test, req)) {
1139
1182
  return res.status(404).json({ message: 'no test founded with this id' });
1140
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
+ }
1141
1190
  const question = new TestQuestion({
1142
1191
  questionType,
1143
1192
  instruction,
1144
1193
  maxScore,
1145
- time
1194
+ time,
1195
+ ...(categoryId != null && categoryId !== '' ? { categoryId } : {})
1146
1196
  });
1147
1197
  await question.save();
1148
1198
  test.questions.push({ questionId: question._id, order: test.questions.length });
@@ -1196,7 +1246,7 @@ class ExamsRouter extends EnduranceRouter {
1196
1246
  if (!test) {
1197
1247
  return res.status(404).json({ message: 'no test founded with this id' });
1198
1248
  }
1199
- if (req.entity?._id && test.entityId && !test.entityId.equals(req.entity._id)) {
1249
+ if (!testBelongsToEntity(test, req)) {
1200
1250
  return res.status(404).json({ message: 'no test founded with this id' });
1201
1251
  }
1202
1252
  const otherQuestionsIds = test.questions.map(question => question.questionId);
@@ -1249,7 +1299,7 @@ class ExamsRouter extends EnduranceRouter {
1249
1299
  if (!test) {
1250
1300
  return res.status(404).json({ message: 'Test not found' });
1251
1301
  }
1252
- if (req.entity?._id && test.entityId && !test.entityId.equals(req.entity._id)) {
1302
+ if (!testBelongsToEntity(test, req)) {
1253
1303
  return res.status(404).json({ message: 'Test not found' });
1254
1304
  }
1255
1305
  for (let i = test.questions.length - 1; i > 0; i--) {
@@ -1302,7 +1352,7 @@ class ExamsRouter extends EnduranceRouter {
1302
1352
  if (!test) {
1303
1353
  return res.status(404).json({ message: 'no test founded with this id' });
1304
1354
  }
1305
- if (req.entity?._id && test.entityId && !test.entityId.equals(req.entity._id)) {
1355
+ if (!testBelongsToEntity(test, req)) {
1306
1356
  return res.status(404).json({ message: 'no test founded with this id' });
1307
1357
  }
1308
1358
  test.invitationText = invitationText;
@@ -1762,6 +1812,8 @@ class ExamsRouter extends EnduranceRouter {
1762
1812
  response.comment = parsedResult.comment;
1763
1813
  }
1764
1814
  result.score = finalscore;
1815
+ const { scoresByCategory } = await computeScoresByCategory(result);
1816
+ result.scoresByCategory = scoresByCategory;
1765
1817
  await result.save();
1766
1818
  res.status(200).json({ data: finalscore });
1767
1819
  }
@@ -1899,7 +1951,7 @@ class ExamsRouter extends EnduranceRouter {
1899
1951
  if (!test) {
1900
1952
  return res.status(404).json({ message: 'Test non trouvé' });
1901
1953
  }
1902
- if (req.entity?._id && test.entityId && !test.entityId.equals(req.entity._id)) {
1954
+ if (!testBelongsToEntity(test, req)) {
1903
1955
  return res.status(404).json({ message: 'Test non trouvé' });
1904
1956
  }
1905
1957
  let categoriesToUse = [];
@@ -2034,7 +2086,7 @@ class ExamsRouter extends EnduranceRouter {
2034
2086
  if (!test) {
2035
2087
  return res.status(404).json({ message: 'Test non trouvé' });
2036
2088
  }
2037
- if (req.entity?._id && test.entityId && !test.entityId.equals(req.entity._id)) {
2089
+ if (!testBelongsToEntity(test, req)) {
2038
2090
  return res.status(404).json({ message: 'Test non trouvé' });
2039
2091
  }
2040
2092
  // Construction de la requête
@@ -2320,8 +2372,10 @@ class ExamsRouter extends EnduranceRouter {
2320
2372
  response.score = score;
2321
2373
  if (typeof comment === 'string')
2322
2374
  response.comment = comment;
2323
- // Recalculer le score global
2324
- result.score = (result.responses || []).reduce((sum, r) => sum + (r.score || 0), 0);
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;
2325
2379
  await result.save();
2326
2380
  return res.status(200).json({
2327
2381
  message: 'Correction manuelle enregistrée',
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@programisto/edrm-exams",
4
- "version": "0.3.10",
4
+ "version": "0.3.12",
5
5
  "publishConfig": {
6
6
  "access": "public"
7
7
  },