@programisto/edrm-exams 0.1.4 → 0.1.5

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.
Files changed (34) hide show
  1. package/README.md +135 -0
  2. package/dist/bin/www.d.ts +2 -0
  3. package/dist/bin/www.js +9 -0
  4. package/dist/modules/edrm-exams/lib/openai/correctQuestion.txt +10 -0
  5. package/dist/modules/edrm-exams/lib/openai/createQuestion.txt +68 -0
  6. package/dist/modules/edrm-exams/lib/openai.d.ts +36 -0
  7. package/dist/modules/edrm-exams/lib/openai.js +82 -0
  8. package/dist/modules/edrm-exams/listeners/correct.listener.d.ts +2 -0
  9. package/dist/modules/edrm-exams/listeners/correct.listener.js +85 -0
  10. package/dist/modules/edrm-exams/models/candidate.models.d.ts +13 -0
  11. package/dist/modules/edrm-exams/models/candidate.models.js +59 -0
  12. package/dist/modules/edrm-exams/models/company.model.d.ts +8 -0
  13. package/dist/modules/edrm-exams/models/company.model.js +34 -0
  14. package/dist/modules/edrm-exams/models/test-category.models.d.ts +7 -0
  15. package/dist/modules/edrm-exams/models/test-category.models.js +29 -0
  16. package/dist/modules/edrm-exams/models/test-question.model.d.ts +25 -0
  17. package/dist/modules/edrm-exams/models/test-question.model.js +70 -0
  18. package/dist/modules/edrm-exams/models/test-result.model.d.ts +26 -0
  19. package/dist/modules/edrm-exams/models/test-result.model.js +70 -0
  20. package/dist/modules/edrm-exams/models/test.model.d.ts +52 -0
  21. package/dist/modules/edrm-exams/models/test.model.js +123 -0
  22. package/dist/modules/edrm-exams/models/user.model.d.ts +18 -0
  23. package/dist/modules/edrm-exams/models/user.model.js +64 -0
  24. package/dist/modules/edrm-exams/routes/company.router.d.ts +7 -0
  25. package/dist/modules/edrm-exams/routes/company.router.js +108 -0
  26. package/dist/modules/edrm-exams/routes/exams-candidate.router.d.ts +7 -0
  27. package/dist/modules/edrm-exams/routes/exams-candidate.router.js +299 -0
  28. package/dist/modules/edrm-exams/routes/exams.router.d.ts +7 -0
  29. package/dist/modules/edrm-exams/routes/exams.router.js +1012 -0
  30. package/dist/modules/edrm-exams/routes/result.router.d.ts +7 -0
  31. package/dist/modules/edrm-exams/routes/result.router.js +314 -0
  32. package/dist/modules/edrm-exams/routes/user.router.d.ts +7 -0
  33. package/dist/modules/edrm-exams/routes/user.router.js +96 -0
  34. package/package.json +73 -8
@@ -0,0 +1,7 @@
1
+ import { EnduranceRouter } from 'endurance-core';
2
+ declare class ResultRouter extends EnduranceRouter {
3
+ constructor();
4
+ setupRoutes(): void;
5
+ }
6
+ declare const router: ResultRouter;
7
+ export default router;
@@ -0,0 +1,314 @@
1
+ import { EnduranceRouter, EnduranceAuthMiddleware, enduranceEmitter, enduranceEventTypes } from 'endurance-core';
2
+ import CandidateModel from '../models/candidate.models.js';
3
+ import TestResult, { TestState } from '../models/test-result.model.js';
4
+ import Test from '../models/test.model.js';
5
+ class ResultRouter extends EnduranceRouter {
6
+ constructor() {
7
+ super(EnduranceAuthMiddleware.getInstance());
8
+ }
9
+ setupRoutes() {
10
+ const authenticatedOptions = {
11
+ requireAuth: false,
12
+ permissions: []
13
+ };
14
+ // Lister tous les résultats de tests d'un candidat
15
+ this.get('/results/:candidateId', authenticatedOptions, async (req, res) => {
16
+ try {
17
+ const { candidateId } = req.params;
18
+ const page = parseInt(req.query.page) || 1;
19
+ const limit = parseInt(req.query.limit) || 10;
20
+ const skip = (page - 1) * limit;
21
+ const state = req.query.state || 'all';
22
+ const sortBy = req.query.sortBy || 'invitationDate';
23
+ const sortOrder = req.query.sortOrder || 'desc';
24
+ // Vérifier si le candidat existe
25
+ const candidate = await CandidateModel.findById(candidateId);
26
+ if (!candidate) {
27
+ return res.status(404).json({ message: 'Candidat non trouvé' });
28
+ }
29
+ // Construction de la requête
30
+ const query = { candidateId };
31
+ if (state !== 'all') {
32
+ query.state = state;
33
+ }
34
+ // Construction du tri
35
+ const allowedSortFields = ['invitationDate', 'state', 'score'];
36
+ const sortField = allowedSortFields.includes(sortBy) ? sortBy : 'invitationDate';
37
+ const sortOptions = {
38
+ [sortField]: sortOrder === 'asc' ? 1 : -1
39
+ };
40
+ const [results, total] = await Promise.all([
41
+ TestResult.find(query)
42
+ .sort(sortOptions)
43
+ .skip(skip)
44
+ .limit(limit)
45
+ .lean()
46
+ .exec(),
47
+ TestResult.countDocuments(query)
48
+ ]);
49
+ // Récupérer les informations des tests associés
50
+ const testIds = results.map(result => result.testId);
51
+ const tests = await Test.find({ _id: { $in: testIds } }).lean();
52
+ const testsMap = new Map(tests.map(test => [test._id.toString(), test]));
53
+ // Récupérer tous les IDs de catégories utilisés dans les tests
54
+ const allCategoryIds = Array.from(new Set(tests.flatMap(test => (test.categories || []).map((cat) => cat.categoryId?.toString()))));
55
+ const TestCategory = (await import('../models/test-category.models.js')).default;
56
+ const categoriesDocs = await TestCategory.find({ _id: { $in: allCategoryIds } }).lean();
57
+ const categoriesMap = new Map(categoriesDocs.map(cat => [cat._id.toString(), cat.name]));
58
+ // Combiner les résultats avec les informations des tests et des catégories
59
+ const TestQuestion = (await import('../models/test-question.model.js')).default;
60
+ const resultsWithTests = await Promise.all(results.map(async (result) => {
61
+ const test = testsMap.get(result.testId.toString());
62
+ let categoriesWithNames = [];
63
+ let maxScore = 0;
64
+ if (test && test.categories) {
65
+ categoriesWithNames = test.categories.map((cat) => ({
66
+ ...cat,
67
+ categoryName: categoriesMap.get(cat.categoryId?.toString()) || 'Catégorie inconnue'
68
+ }));
69
+ }
70
+ if (test && test.questions && test.questions.length > 0) {
71
+ const questionIds = test.questions.map((q) => q.questionId || q);
72
+ const questions = await TestQuestion.find({ _id: { $in: questionIds } }).lean();
73
+ maxScore = questions.reduce((sum, q) => sum + (q.maxScore || 0), 0);
74
+ }
75
+ const { responses, ...resultWithoutResponses } = result;
76
+ return {
77
+ ...resultWithoutResponses,
78
+ testResultId: result._id,
79
+ maxScore,
80
+ test: test
81
+ ? {
82
+ title: test.title,
83
+ description: test.description,
84
+ targetJob: test.targetJob,
85
+ seniorityLevel: test.seniorityLevel,
86
+ categories: categoriesWithNames
87
+ }
88
+ : null
89
+ };
90
+ }));
91
+ const totalPages = Math.ceil(total / limit);
92
+ return res.json({
93
+ data: resultsWithTests,
94
+ pagination: {
95
+ currentPage: page,
96
+ totalPages,
97
+ totalItems: total,
98
+ itemsPerPage: limit,
99
+ hasNextPage: page < totalPages,
100
+ hasPreviousPage: page > 1
101
+ }
102
+ });
103
+ }
104
+ catch (err) {
105
+ console.error('Erreur lors de la récupération des résultats :', err);
106
+ res.status(500).json({ message: 'Erreur interne du serveur' });
107
+ }
108
+ });
109
+ // Obtenir les infos de base d'un test (sans les questions), avec categoryName et maxTime
110
+ this.get('/test/:id', authenticatedOptions, async (req, res) => {
111
+ try {
112
+ const { id } = req.params;
113
+ const TestCategory = (await import('../models/test-category.models.js')).default;
114
+ const TestQuestion = (await import('../models/test-question.model.js')).default;
115
+ // Récupérer le test sans les questions
116
+ const test = await Test.findById(id).lean();
117
+ if (!test) {
118
+ return res.status(404).json({ message: 'Test non trouvé' });
119
+ }
120
+ // Récupérer les noms des catégories
121
+ const categoryIds = (test.categories || []).map((cat) => cat.categoryId?.toString());
122
+ const categoriesDocs = await TestCategory.find({ _id: { $in: categoryIds } }).lean();
123
+ const categoriesMap = new Map(categoriesDocs.map(cat => [cat._id.toString(), cat.name]));
124
+ const categoriesWithNames = (test.categories || []).map((cat) => ({
125
+ ...cat,
126
+ categoryName: categoriesMap.get(cat.categoryId?.toString()) || 'Catégorie inconnue'
127
+ }));
128
+ // Calculer la somme du temps de toutes les questions
129
+ const questions = await TestQuestion.find({ _id: { $in: (test.questions || []).map((q) => q.questionId) } }).lean();
130
+ const maxTime = questions.reduce((sum, q) => sum + (q.time || 0), 0);
131
+ // Construire la réponse sans les questions
132
+ const { questions: _questions, // on retire les questions
133
+ ...testWithoutQuestions } = test;
134
+ return res.json({
135
+ ...testWithoutQuestions,
136
+ categories: categoriesWithNames,
137
+ maxTime
138
+ });
139
+ }
140
+ catch (err) {
141
+ console.error('Erreur lors de la récupération du test :', err);
142
+ res.status(500).json({ message: 'Erreur interne du serveur' });
143
+ }
144
+ });
145
+ // Obtenir l'ID de la prochaine question non répondue pour un résultat de test
146
+ this.get('/:id/nextQuestion', authenticatedOptions, async (req, res) => {
147
+ try {
148
+ const { id } = req.params;
149
+ const { currentQuestionId } = req.query;
150
+ // Récupérer le résultat de test
151
+ const result = await TestResult.findById(id);
152
+ if (!result) {
153
+ return res.status(404).json({ message: 'Résultat non trouvé' });
154
+ }
155
+ // Récupérer le test associé
156
+ const test = await Test.findById(result.testId).lean();
157
+ if (!test) {
158
+ return res.status(404).json({ message: 'Test non trouvé' });
159
+ }
160
+ // Liste des questions du test dans l'ordre
161
+ const questions = test.questions || [];
162
+ if (currentQuestionId) {
163
+ // Si on a un currentQuestionId, on cherche la question suivante dans l'ordre
164
+ const currentIndex = questions.findIndex(q => (q.questionId ? q.questionId.toString() : q.toString()) === currentQuestionId);
165
+ if (currentIndex === -1) {
166
+ return res.status(404).json({ message: 'Question courante non trouvée' });
167
+ }
168
+ // Si c'est la dernière question
169
+ if (currentIndex === questions.length - 1) {
170
+ // On est sur la dernière réponse, on met à jour la date de fin
171
+ result.endTime = new Date();
172
+ await result.save();
173
+ return res.json({ nextQuestionId: 'result' });
174
+ }
175
+ // Retourner la question suivante
176
+ const nextQuestion = questions[currentIndex + 1];
177
+ // Si c'est la première question (currentIndex === -1 avant), on met à jour la date de début
178
+ if (currentIndex === 0 && !result.startTime) {
179
+ result.startTime = new Date();
180
+ await result.save();
181
+ }
182
+ return res.json({
183
+ nextQuestionId: nextQuestion.questionId
184
+ ? nextQuestion.questionId.toString()
185
+ : nextQuestion.toString()
186
+ });
187
+ }
188
+ else {
189
+ // Comportement original : chercher la première question non répondue
190
+ const answeredIds = (result.responses || []).map((r) => r.questionId.toString());
191
+ let nextQuestionId = null;
192
+ for (const q of questions) {
193
+ const qid = (q.questionId ? q.questionId.toString() : q.toString());
194
+ if (!answeredIds.includes(qid)) {
195
+ nextQuestionId = qid;
196
+ break;
197
+ }
198
+ }
199
+ if (!nextQuestionId) {
200
+ // Plus de question à répondre, on met à jour la date de fin
201
+ result.endTime = new Date();
202
+ await result.save();
203
+ nextQuestionId = 'result';
204
+ }
205
+ else if (questions.length > 0 && nextQuestionId === (questions[0].questionId ? questions[0].questionId.toString() : questions[0].toString()) && !result.startTime) {
206
+ // Si c'est la première question, on met à jour la date de début
207
+ result.startTime = new Date();
208
+ await result.save();
209
+ }
210
+ return res.json({ nextQuestionId });
211
+ }
212
+ }
213
+ catch (err) {
214
+ console.error('Erreur lors de la récupération de la prochaine question :', err);
215
+ res.status(500).json({ message: 'Erreur interne du serveur' });
216
+ }
217
+ });
218
+ // Afficher une question par son ID (optionnellement vérifier la session)
219
+ this.get('/question/:idQuestion', authenticatedOptions, async (req, res) => {
220
+ try {
221
+ const { idQuestion } = req.params;
222
+ const { sessionId } = req.query;
223
+ const TestQuestion = (await import('../models/test-question.model.js')).default;
224
+ // Récupérer la question
225
+ const question = await TestQuestion.findById(idQuestion).lean();
226
+ if (!question) {
227
+ return res.status(404).json({ message: 'Question non trouvée' });
228
+ }
229
+ // Optionnel : vérifier que la question appartient bien au test de la session et n'a pas déjà été répondue
230
+ if (sessionId) {
231
+ const result = await TestResult.findById(sessionId).lean();
232
+ if (!result) {
233
+ return res.status(404).json({ message: 'Session (résultat) non trouvée' });
234
+ }
235
+ const test = await Test.findById(result.testId).lean();
236
+ if (!test) {
237
+ return res.status(404).json({ message: 'Test non trouvé' });
238
+ }
239
+ const questionIds = (test.questions || []).map((q) => q.questionId?.toString());
240
+ if (!questionIds.includes(idQuestion)) {
241
+ return res.status(403).json({ message: 'Question non autorisée pour cette session' });
242
+ }
243
+ // Vérifier que la question n'a pas déjà été répondue
244
+ const alreadyAnswered = (result.responses || []).some((r) => r.questionId?.toString() === idQuestion);
245
+ if (alreadyAnswered) {
246
+ return res.status(403).json({ message: 'Question déjà répondue pour cette session' });
247
+ }
248
+ }
249
+ return res.json({ question });
250
+ }
251
+ catch (err) {
252
+ console.error('Erreur lors de la récupération de la question :', err);
253
+ res.status(500).json({ message: 'Erreur interne du serveur' });
254
+ }
255
+ });
256
+ // Enregistrer la réponse à une question pour un résultat de test
257
+ this.post('/response', authenticatedOptions, async (req, res) => {
258
+ try {
259
+ const { response, questionId, testResultId } = req.body;
260
+ // Récupérer le résultat de test
261
+ const result = await TestResult.findById(testResultId);
262
+ if (!result) {
263
+ return res.status(404).json({ message: 'Résultat non trouvé' });
264
+ }
265
+ // Récupérer le test associé
266
+ const test = await Test.findById(result.testId);
267
+ if (!test) {
268
+ return res.status(404).json({ message: 'Test non trouvé' });
269
+ }
270
+ // Vérifier que la question appartient bien au test
271
+ const questionIds = (test.questions || []).map((q) => q.questionId?.toString());
272
+ if (!questionIds.includes(questionId)) {
273
+ return res.status(403).json({ message: 'Question non autorisée pour ce test' });
274
+ }
275
+ // Vérifier que la question n'a pas déjà été répondue
276
+ const alreadyAnswered = (result.responses || []).some((r) => r.questionId?.toString() === questionId);
277
+ if (alreadyAnswered) {
278
+ return res.status(403).json({ message: 'Question déjà répondue pour cette session' });
279
+ }
280
+ // Enregistrer la réponse
281
+ result.responses = result.responses || [];
282
+ result.responses.push({
283
+ questionId,
284
+ response,
285
+ score: 0,
286
+ comment: ''
287
+ });
288
+ // Vérifier si c'était la dernière question
289
+ const totalQuestions = test.questions.length;
290
+ const answeredQuestions = result.responses.length;
291
+ if (answeredQuestions === totalQuestions) {
292
+ result.state = TestState.Finish;
293
+ // Déclencher la correction automatique
294
+ await enduranceEmitter.emit(enduranceEventTypes.CORRECT_TEST, result);
295
+ }
296
+ else {
297
+ result.state = TestState.InProgress;
298
+ }
299
+ await result.save();
300
+ return res.status(200).json({
301
+ message: 'Réponse enregistrée',
302
+ response,
303
+ isLastQuestion: answeredQuestions === totalQuestions
304
+ });
305
+ }
306
+ catch (err) {
307
+ console.error('Erreur lors de l\'enregistrement de la réponse :', err);
308
+ res.status(500).json({ message: 'Erreur interne du serveur' });
309
+ }
310
+ });
311
+ }
312
+ }
313
+ const router = new ResultRouter();
314
+ export default router;
@@ -0,0 +1,7 @@
1
+ import { EnduranceRouter } from 'endurance-core';
2
+ declare class UserRouter extends EnduranceRouter {
3
+ constructor();
4
+ setupRoutes(): void;
5
+ }
6
+ declare const router: UserRouter;
7
+ export default router;
@@ -0,0 +1,96 @@
1
+ import { EnduranceRouter, EnduranceAuthMiddleware } from 'endurance-core';
2
+ import User from '../models/user.model.js';
3
+ class UserRouter extends EnduranceRouter {
4
+ constructor() {
5
+ super(EnduranceAuthMiddleware.getInstance());
6
+ }
7
+ setupRoutes() {
8
+ const authenticatedOptions = {
9
+ requireAuth: false,
10
+ permissions: []
11
+ };
12
+ // Lister tous les utilisateurs
13
+ this.get('/', authenticatedOptions, async (req, res) => {
14
+ try {
15
+ const users = await User.find();
16
+ res.status(200).json({ array: users });
17
+ }
18
+ catch (err) {
19
+ console.error('Error when retrieving users: ', err);
20
+ res.status(500).json({ message: 'Internal server error' });
21
+ }
22
+ });
23
+ // Créer un utilisateur
24
+ this.post('/create', authenticatedOptions, async (req, res) => {
25
+ const { firstName, lastName, email, companyId } = req.body;
26
+ if (!firstName || !lastName || !email || !companyId) {
27
+ return res.status(400).json({ message: 'Error, firstName, lastName, email and companyId are required' });
28
+ }
29
+ try {
30
+ const newUser = new User({ firstName, lastName, email, companyId });
31
+ await newUser.save();
32
+ res.status(201).json({ message: 'user created with sucess', user: newUser });
33
+ }
34
+ catch (err) {
35
+ console.error('error when creating user : ', err);
36
+ res.status(500).json({ message: 'Internal server error' });
37
+ }
38
+ });
39
+ // Obtenir un utilisateur par son ID
40
+ this.get('/:id', authenticatedOptions, async (req, res) => {
41
+ const { id } = req.params;
42
+ try {
43
+ const user = await User.findById(id);
44
+ if (!user) {
45
+ return res.status(404).json({ message: 'no user founded with this id' });
46
+ }
47
+ res.status(200).json({ data: user });
48
+ }
49
+ catch (err) {
50
+ console.error('error when geting user : ', err);
51
+ res.status(500).json({ message: 'Internal server error' });
52
+ }
53
+ });
54
+ // Mettre à jour un utilisateur
55
+ this.put('/:id', authenticatedOptions, async (req, res) => {
56
+ const { id } = req.params;
57
+ const { firstName, lastName, email, companyId } = req.body;
58
+ try {
59
+ const user = await User.findById(id);
60
+ if (!user) {
61
+ return res.status(404).json({ message: 'no user founded with this id' });
62
+ }
63
+ const updateData = {
64
+ firstName: firstName || user.firstName,
65
+ lastName: lastName || user.lastName,
66
+ email: email || user.email,
67
+ companyId: companyId || user.companyId
68
+ };
69
+ await User.findByIdAndUpdate(id, updateData, { new: true });
70
+ const updatedUser = await User.findById(id);
71
+ res.status(200).json({ message: 'user updated', user: updatedUser });
72
+ }
73
+ catch (err) {
74
+ console.error('error when updating user : ', err);
75
+ res.status(500).json({ message: 'Internal server error' });
76
+ }
77
+ });
78
+ // Supprimer un utilisateur
79
+ this.delete('/:id', authenticatedOptions, async (req, res) => {
80
+ const { id } = req.params;
81
+ try {
82
+ const user = await User.findByIdAndDelete(id);
83
+ if (!user) {
84
+ return res.status(404).json({ message: 'no user founded with this id' });
85
+ }
86
+ res.status(200).json({ message: 'user deleted', user });
87
+ }
88
+ catch (err) {
89
+ console.error('error when deleting user : ', err);
90
+ res.status(500).json({ message: 'Internal server error' });
91
+ }
92
+ });
93
+ }
94
+ }
95
+ const router = new UserRouter();
96
+ export default router;
package/package.json CHANGED
@@ -1,15 +1,80 @@
1
1
  {
2
+ "type": "module",
2
3
  "name": "@programisto/edrm-exams",
3
- "version": "0.1.4",
4
+ "version": "0.1.5",
4
5
  "publishConfig": {
5
6
  "access": "public"
6
7
  },
7
- "description": "endurance module with basic exams route",
8
- "license": "ISC",
9
- "author": "",
10
- "type": "commonjs",
11
- "main": "index.js",
8
+ "description": "an api for online exams",
9
+ "main": "app.js",
12
10
  "scripts": {
13
- "test": "echo \"Error: no test specified\" && exit 1"
14
- }
11
+ "start": "node ./dist/bin/www",
12
+ "dev": "tsc-watch --onSuccess \"node ./dist/bin/www\"",
13
+ "test": "mocha",
14
+ "build": "tsc && npm run copy-files",
15
+ "copy-files": "copyfiles -u 1 'src/**/*.txt' dist",
16
+ "lint": "eslint \"**/*.{ts,tsx}\"",
17
+ "prepare": "husky install"
18
+ },
19
+ "dependencies": {
20
+ "aws-sdk": "^2.1692.0",
21
+ "debug": "^4.3.7",
22
+ "edrm-mailer": "^0.4.5",
23
+ "edrm-user": "^0.4.9",
24
+ "endurance-core": "^0.4.25",
25
+ "fs": "^0.0.1-security",
26
+ "http": "^0.0.1-security",
27
+ "jsonwebtoken": "^9.0.2",
28
+ "mongoose": "^8.8.3",
29
+ "multer": "^1.4.5-lts.2",
30
+ "multer-s3": "^3.0.1",
31
+ "mustache": "^4.2.0",
32
+ "nodemailer": "^6.10.1",
33
+ "openai": "^4.100.0",
34
+ "path": "^0.12.7",
35
+ "to-regex-range": "^5.0.1",
36
+ "tsc-watch": "^6.2.1",
37
+ "url": "^0.11.4",
38
+ "uuid": "^11.0.3"
39
+ },
40
+ "devDependencies": {
41
+ "@commitlint/config-conventional": "^19.8.1",
42
+ "@semantic-release/changelog": "^6.0.3",
43
+ "@semantic-release/commit-analyzer": "^13.0.1",
44
+ "@semantic-release/git": "^10.0.1",
45
+ "@semantic-release/github": "^11.0.3",
46
+ "@semantic-release/npm": "^12.0.1",
47
+ "@semantic-release/release-notes-generator": "^14.0.3",
48
+ "@types/node": "^22.15.3",
49
+ "commitlint": "^19.8.1",
50
+ "copyfiles": "^2.4.1",
51
+ "@typescript-eslint/eslint-plugin": "^8.26.0",
52
+ "@typescript-eslint/parser": "^8.26.0",
53
+ "eslint": "^8.57.1",
54
+ "eslint-config-standard": "^17.1.0",
55
+ "eslint-plugin-import": "^2.31.0",
56
+ "eslint-plugin-n": "^16.6.2",
57
+ "eslint-plugin-promise": "^6.6.0",
58
+ "husky": "^9.1.7",
59
+ "mocha": "^11.0.1",
60
+ "nodemon": "^3.1.4",
61
+ "semantic-release": "^24.2.5",
62
+ "supertest": "^3.0.0",
63
+ "ts-node": "^10.9.2",
64
+ "typescript": "^5.8.3"
65
+ },
66
+ "repository": {
67
+ "type": "git",
68
+ "url": "git+https://github.com/programisto-labs/edrm-exams.git"
69
+ },
70
+ "author": "",
71
+ "license": "ISC",
72
+ "bugs": {
73
+ "url": "https://github.com/programisto-labs/edrm-exams/issues"
74
+ },
75
+ "homepage": "https://github.com/programisto-labs/edrm-exams#readme",
76
+ "files": [
77
+ "dist",
78
+ "README.md"
79
+ ]
15
80
  }