data-primals-engine 1.7.2 → 1.7.3

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 (78) hide show
  1. package/README.md +160 -160
  2. package/client/package-lock.json +8430 -8430
  3. package/client/src/APIInfo.jsx +1 -1
  4. package/client/src/App.jsx +2 -1
  5. package/client/src/App.scss +1635 -1626
  6. package/client/src/AssistantChat.jsx +1 -0
  7. package/client/src/CalendarView.jsx +1 -0
  8. package/client/src/ContentView.jsx +3 -3
  9. package/client/src/Dashboard.jsx +5 -2
  10. package/client/src/DashboardChart.jsx +1 -0
  11. package/client/src/DashboardFlexViewItem.jsx +1 -0
  12. package/client/src/DashboardHtmlViewItem.jsx +1 -0
  13. package/client/src/DashboardView.jsx +2 -0
  14. package/client/src/DataEditor.jsx +0 -1
  15. package/client/src/DataImporter.jsx +489 -468
  16. package/client/src/DataLayout.jsx +6 -3
  17. package/client/src/DataTable.jsx +4 -3
  18. package/client/src/Dialog.jsx +92 -90
  19. package/client/src/Dialog.scss +122 -116
  20. package/client/src/DisplayFlexNodeRenderer.jsx +1 -0
  21. package/client/src/FlexBuilderControls.jsx +1 -0
  22. package/client/src/FlexBuilderPreview.jsx +1 -1
  23. package/client/src/FlexNode.jsx +1 -1
  24. package/client/src/HistoryDialog.jsx +3 -2
  25. package/client/src/KPIWidget.jsx +1 -1
  26. package/client/src/KanbanView.jsx +1 -0
  27. package/client/src/ModelCreator.jsx +4 -0
  28. package/client/src/ModelList.jsx +1 -0
  29. package/client/src/PackGallery.jsx +5 -4
  30. package/client/src/RTETrans.jsx +1 -1
  31. package/client/src/RelationField.jsx +2 -0
  32. package/client/src/RelationSelectorWidget.jsx +2 -0
  33. package/client/src/RelationValue.jsx +1 -0
  34. package/client/src/RestoreDialog.jsx +1 -0
  35. package/client/src/WorkflowEditor.jsx +3 -0
  36. package/client/src/contexts/CommandContext.jsx +2 -1
  37. package/client/src/contexts/ModelContext.jsx +3 -3
  38. package/client/src/hooks/data.js +1 -0
  39. package/client/src/hooks/useValidation.js +1 -0
  40. package/client/src/translations.js +24 -24
  41. package/doc/AI-assistance.md +87 -63
  42. package/doc/Concepts.md +122 -0
  43. package/doc/Custom-Endpoints.md +31 -0
  44. package/doc/Event-system.md +13 -14
  45. package/doc/Home.md +33 -0
  46. package/doc/Modules.md +83 -0
  47. package/doc/Packs-gallery.md +8 -23
  48. package/doc/Workflows.md +32 -0
  49. package/doc/automation-workflows.md +141 -102
  50. package/doc/dashboards-kpis-charts.md +47 -49
  51. package/doc/data-management.md +126 -120
  52. package/doc/data-models.md +68 -75
  53. package/doc/roles-permissions.md +144 -43
  54. package/doc/sharding-replication.md +158 -0
  55. package/doc/users.md +54 -30
  56. package/package.json +1 -1
  57. package/server.js +37 -37
  58. package/src/constants.js +7 -8
  59. package/src/data.js +521 -520
  60. package/src/email.js +157 -154
  61. package/src/engine.js +117 -7
  62. package/src/gameObject.js +6 -0
  63. package/src/i18n.js +0 -1
  64. package/src/modules/auth-google/index.js +53 -50
  65. package/src/modules/bucket.js +346 -339
  66. package/src/modules/data/data.backup.js +400 -376
  67. package/src/modules/data/data.cluster.js +410 -42
  68. package/src/modules/data/data.operations.js +3666 -3635
  69. package/src/modules/data/data.replication.js +106 -64
  70. package/src/modules/data/data.routes.js +87 -64
  71. package/src/modules/data/data.scheduling.js +2 -1
  72. package/src/modules/file.js +248 -247
  73. package/src/modules/user.js +11 -1
  74. package/src/sso.js +91 -25
  75. package/test/cluster.test.js +221 -0
  76. package/test/core.test.js +0 -2
  77. package/test/replication.test.js +163 -0
  78. package/doc/core-concepts.md +0 -33
@@ -1,83 +1,125 @@
1
- // --- NOUVEAU FICHIER : src/modules/data/data.replication.js ---
2
- import { Event } from '../../events.js';
3
1
  import { Logger } from '../../gameObject.js';
4
- import { getResponsibleNodesForUser, isSelfMasterForUser } from './data.cluster.js';
2
+ import { getReplicaNodesForUser } from './data.cluster.js';
3
+ import process from "node:process";
5
4
 
6
- let logger;
5
+ const logger = new Logger('DataReplication');
6
+ const replicationQueue = [];
7
+ const failedBatchesByReplica = new Map(); // Map<peerId, Array<chunk>>
8
+ const BATCH_INTERVAL = 100; // Traiter la file toutes les 100ms
9
+ const BATCH_SIZE_LIMIT = 50; // Envoyer un maximum de 50 opérations par requête de batch
10
+
11
+ let intervalId = null;
12
+ let engineInstance = null; // Pour stocker l'instance du moteur
7
13
 
8
14
  /**
9
- * Réplique une opération de données vers les nœuds répliques.
15
+ * Ajoute une tâche de réplication à la file d'attente.
16
+ * C'est le point d'entrée pour toutes les opérations de données.
10
17
  * @param {string} operation - 'insert', 'update', 'delete'
11
18
  * @param {string} modelName - Le nom du modèle.
12
19
  * @param {object} user - L'objet utilisateur.
13
20
  * @param {object} payload - Les données de l'opération.
14
21
  */
15
- async function replicateOperation(operation, modelName, user, payload) {
16
- if (!isSelfMasterForUser(user.username)) {
17
- // Seul le nœud maître est autorisé à initier une réplication.
22
+ export function queueReplication(operation, modelName, user, payload) {
23
+ replicationQueue.push({ operation, modelName, user, payload, timestamp: new Date() });
24
+ }
25
+
26
+ /**
27
+ * Tente de renvoyer les lots qui ont précédemment échoué pour chaque réplique.
28
+ */
29
+ async function retryFailedBatches() {
30
+ if (failedBatchesByReplica.size === 0) {
31
+ return;
32
+ }
33
+
34
+ logger.debug(`[ReplicationRetry] Checking ${failedBatchesByReplica.size} replica(s) for failed batches to retry.`);
35
+
36
+ for (const [peerId, batches] of failedBatchesByReplica.entries()) {
37
+ if (batches.length > 0) {
38
+ const batchToRetry = batches.shift(); // On prend le plus ancien lot en échec
39
+ logger.info(`[ReplicationRetry] Retrying to send batch of ${batchToRetry.length} operations to replica peer ${peerId}`);
40
+
41
+ try {
42
+ await engineInstance.sendToPeer(peerId, '/api/internal/replicate', { operations: batchToRetry });
43
+ // Si l'envoi réussit, le lot est retiré de la file. Sinon, il reste pour le prochain essai.
44
+ } catch (error) {
45
+ logger.error(`[ReplicationRetry] Retry failed for peer ${peerId}: ${error.message}. Re-queuing batch.`);
46
+ batches.unshift(batchToRetry); // On le remet au début pour réessayer plus tard
47
+ }
48
+ }
49
+ }
50
+ }
51
+ /**
52
+ * Traite la file d'attente, groupe les opérations par réplique et les envoie en lots.
53
+ */
54
+ async function processReplicationQueue() {
55
+ // Si les instances partagent la même base de données, la réplication est inutile.
56
+ if (process.env.CLUSTER_SHARED_DATABASE === 'true') {
57
+ if (replicationQueue.length > 0) replicationQueue.length = 0;
18
58
  return;
19
59
  }
20
60
 
21
- const nodes = getResponsibleNodesForUser(user.username);
22
- const replicas = nodes.slice(1); // Tous les nœuds sauf le premier (le maître)
61
+ // D'abord, on essaie de vider les anciennes tâches en échec
62
+ await retryFailedBatches();
23
63
 
24
- if (replicas.length === 0) {
25
- return; // Pas de répliques à notifier.
64
+ if (replicationQueue.length === 0) {
65
+ return;
26
66
  }
27
67
 
28
- logger.debug(`[Replication] Replicating '${operation}' on model '${modelName}' for user '${user.username}' to replicas: ${replicas.join(', ')}`);
29
-
30
- const replicationPayload = {
31
- operation,
32
- modelName,
33
- user: { username: user.username, _user: user._user }, // Envoyer juste l'essentiel
34
- payload
35
- };
36
-
37
- const promises = replicas.map(replicaUrl => {
38
- const targetUrl = `${replicaUrl}/api/internal/replicate`;
39
- const authToken = process.env.INTERNAL_CLUSTER_TOKEN ? `Bearer ${process.env.INTERNAL_CLUSTER_TOKEN}` : null;
40
-
41
- return fetch(targetUrl, {
42
- method: 'POST',
43
- headers: {
44
- 'Content-Type': 'application/json',
45
- 'Authorization': authToken
46
- },
47
- body: JSON.stringify(replicationPayload)
48
- }).catch(err => {
49
- logger.error(`[Replication] Failed to replicate to ${replicaUrl}: ${err.message}`);
50
- });
51
- });
52
-
53
- // On ne bloque pas la réponse à l'utilisateur, la réplication est asynchrone.
54
- Promise.allSettled(promises);
68
+ const itemsToProcess = replicationQueue.splice(0, replicationQueue.length);
69
+ logger.debug(`[ReplicationQueue] Processing ${itemsToProcess.length} items.`);
70
+
71
+ // Regrouper les opérations par ID de nœud de réplique
72
+ const batchesByReplica = new Map();
73
+
74
+ for (const item of itemsToProcess) {
75
+ // Pour chaque opération, on trouve les nœuds de réplique responsables
76
+ const replicaNodes = getReplicaNodesForUser(item.user.username);
77
+
78
+ for (const replica of replicaNodes) {
79
+ if (!batchesByReplica.has(replica.id)) {
80
+ batchesByReplica.set(replica.id, []);
81
+ }
82
+ batchesByReplica.get(replica.id).push(item);
83
+ }
84
+ }
85
+
86
+ // Envoyer les lots à chaque réplique concernée
87
+ for (const [peerId, operations] of batchesByReplica.entries()) {
88
+ for (let i = 0; i < operations.length; i += BATCH_SIZE_LIMIT) {
89
+ const chunk = operations.slice(i, i + BATCH_SIZE_LIMIT);
90
+
91
+ logger.info(`[ReplicationBatch] Sending batch of ${chunk.length} operations to replica peer ${peerId}`);
92
+
93
+ try {
94
+ await engineInstance.sendToPeer(peerId, '/api/internal/replicate', { operations: chunk });
95
+ } catch (error) {
96
+ logger.error(`[ReplicationBatch] Failed to send batch to replica peer ${peerId}: ${error.message}. Queueing for retry.`);
97
+ // En cas d'échec, on ajoute le lot à la file d'attente des échecs pour cette réplique
98
+ if (!failedBatchesByReplica.has(peerId)) failedBatchesByReplica.set(peerId, []);
99
+ failedBatchesByReplica.get(peerId).push(chunk);
100
+ }
101
+ }
102
+ }
55
103
  }
56
104
 
105
+ /**
106
+ * Démarre le processeur de la file d'attente de réplication.
107
+ * @param {object} engine - L'instance du moteur.
108
+ */
57
109
  export function onInit(engine) {
58
- logger = engine.getComponent(Logger);
59
-
60
- // Écouter les événements de modification de données
61
- Event.Listen("OnDataAdded", (eng, { modelName, insertedDocs, user }) => {
62
- replicateOperation('insert', modelName, user, { data: insertedDocs });
63
- }, "event", "system");
64
-
65
- Event.Listen("OnDataEdited", (eng, { modelName, user, after }) => {
66
- // Pour une mise à jour, on a besoin du filtre et des données modifiées.
67
- // C'est un peu plus complexe car l'événement ne fournit pas le filtre original.
68
- // Pour une réplication simple, on peut envoyer l'ID et le payload de mise à jour.
69
- after.forEach(doc => {
70
- replicateOperation('update', modelName, user, {
71
- filter: { _id: doc._id.toString() },
72
- data: doc // Envoyer le document complet est plus simple pour la réplique.
73
- });
74
- });
75
- }, "event", "system");
76
-
77
- Event.Listen("OnDataDeleted", (eng, { modelName, user, before }) => {
78
- const idsToDelete = before.map(doc => doc._id.toString());
79
- replicateOperation('delete', modelName, user, { ids: idsToDelete });
80
- }, "event", "system");
81
-
82
- logger.info("Replication module initialized and listening for data events.");
110
+ engineInstance = engine; // Stocker l'instance du moteur
111
+ if (engineInstance.peers.length > 1) {
112
+ intervalId = setInterval(processReplicationQueue, BATCH_INTERVAL);
113
+ logger.info(`Replication Queue processor started with a ${BATCH_INTERVAL}ms interval.`);
114
+ }
115
+ }
116
+
117
+ /**
118
+ * Arrête le processeur de la file d'attente.
119
+ */
120
+ export function stopReplicationQueue() {
121
+ if (intervalId) {
122
+ clearInterval(intervalId);
123
+ logger.info('Replication Queue processor stopped.');
124
+ }
83
125
  }
@@ -3,10 +3,9 @@ import {ObjectId} from "mongodb";
3
3
  import * as util from 'node:util';
4
4
  import {setTimeoutMiddleware} from '../../middlewares/timeout.js';
5
5
  import {isDemoUser, isLocalUser} from "../../data.js";
6
- import {
7
- getHost,
6
+ import {
7
+ getHost,
8
8
  install,
9
- clusterPeers, // Importer la configuration du cluster
10
9
  maxBytesPerSecondThrottleData,
11
10
  maxMagnetsDataPerModel,
12
11
  maxMagnetsModels,
@@ -28,7 +27,7 @@ import {tutorialsConfig} from "../../../client/src/tutorials.js";
28
27
  import {getResource, handleDemoInitialization} from "./data.js";
29
28
  import process from "node:process";
30
29
  import {throttleMiddleware} from "../../middlewares/throttle.js";
31
- import {modelsCache} from "./data.core.js";
30
+ import {modelsCache, runImportExportWorker} from "./data.core.js";
32
31
  import {validateModelData, validateModelStructure} from "./data.validation.js";
33
32
  import {
34
33
  deleteData,
@@ -45,8 +44,8 @@ import {
45
44
  } from "./data.operations.js";
46
45
  import { dumpUserData, loadFromDump } from "./data.backup.js";
47
46
  import { invalidateModelCache } from "./data.operations.js";
48
- import { findFirstAvailableProvider, getAIProvider } from "../assistant/providers.js";
49
- import { isProxiedRequest, proxyRequest } from './data.cluster.js';
47
+ import { findFirstAvailableProvider, getAIProvider } from '../assistant/providers.js';
48
+ import { isProxiedRequest, proxyRequest, onInit as clusterInit } from './data.cluster.js';
50
49
  import {providers} from "../assistant/constants.js";
51
50
 
52
51
  let logger, engine;
@@ -442,6 +441,8 @@ export async function registerRoutes(defaultEngine){
442
441
  engine = defaultEngine;
443
442
  logger = engine.getComponent(Logger);
444
443
 
444
+ clusterInit(engine);
445
+
445
446
  // NOUVEAU : Initialisation du scaling SSE (via MongoDB Change Streams)
446
447
  initializeSseScaling();
447
448
 
@@ -460,66 +461,61 @@ export async function registerRoutes(defaultEngine){
460
461
  };
461
462
 
462
463
  engine.post('/api/internal/replicate', [internalAuth], async (req, res) => {
463
- const { operation, modelName, user, payload } = req.fields;
464
- logger.info(`[Replication] Received replication job: ${operation} on ${modelName} for ${user.username}`);
464
+ const { operations } = req.fields; // Le corps contient maintenant un tableau d'opérations
465
+ if (!Array.isArray(operations)) {
466
+ return res.status(400).json({ success: false, error: 'Expected an array of operations.' });
467
+ }
465
468
 
466
- try {
467
- // IMPORTANT: Le dernier paramètre `false` désactive le déclenchement de workflow
468
- // pour éviter une boucle de réplication infinie.
469
- const collection = await getCollectionForUser(user);
470
-
471
- switch (operation) {
472
- case 'insert':
473
- // --- LWW: On ne fait l'insert que si le document n'existe pas déjà ---
474
- // On vérifie si un document avec le même hash existe déjà.
475
- const incomingDoc = payload.data[0];
476
- const existingDoc = await collection.findOne({ _hash: incomingDoc._hash, _model: modelName, _user: user.username });
477
- const incomingTimestampInsert = new Date(incomingDoc._lastModifiedAt);
478
-
479
- if (existingDoc && new Date(existingDoc._lastModifiedAt) >= incomingTimestampInsert) {
480
- // Une version plus récente ou identique existe déjà, on ignore l'insert.
481
- logger.warn(`[Replication-LWW] Stale insert for ${modelName} with hash ${incomingDoc._hash} ignored. Local: ${existingDoc._lastModifiedAt.toISOString()}, Incoming: ${incomingTimestampInsert.toISOString()}`);
482
- } else if (existingDoc) {
483
- // Une version plus ancienne existe, on la remplace.
484
- await collection.replaceOne({ _id: existingDoc._id }, incomingDoc);
485
- } else {
486
- await collection.insertOne(incomingDoc);
469
+ logger.info(`[Replication] Received batch of ${operations.length} replication jobs.`);
470
+
471
+ for (const { operation, modelName, user, payload } of operations) {
472
+ try {
473
+ const collection = await getCollectionForUser(user);
474
+
475
+ switch (operation) {
476
+ case 'insert': {
477
+ const incomingDoc = Array.isArray(payload.data) ? payload.data[0] : payload.data;
478
+ if (!incomingDoc) continue;
479
+ const existingDoc = await collection.findOne({ _hash: incomingDoc._hash, _model: modelName, _user: user.username });
480
+ const incomingTimestampInsert = new Date(incomingDoc._lastModifiedAt);
481
+
482
+ if (existingDoc && new Date(existingDoc._lastModifiedAt) >= incomingTimestampInsert) {
483
+ logger.warn(`[Replication-LWW] Stale insert for ${modelName} with hash ${incomingDoc._hash} ignored. Local: ${existingDoc._lastModifiedAt.toISOString()}, Incoming: ${incomingTimestampInsert.toISOString()}`);
484
+ } else if (existingDoc) {
485
+ await collection.replaceOne({ _id: existingDoc._id }, incomingDoc);
486
+ } else {
487
+ await collection.insertOne(incomingDoc);
488
+ }
489
+ break;
487
490
  }
488
- break;
489
- case 'update':
490
- // --- LWW: Logique de comparaison des timestamps ---
491
- const docToUpdate = await collection.findOne(payload.filter);
492
- const incomingTimestamp = new Date(payload.data._lastModifiedAt);
493
-
494
- if (!docToUpdate) {
495
- // Si le document n'existe pas localement, on le crée.
496
- // Cela peut arriver si des réplications arrivent dans le désordre.
497
- logger.info(`[Replication-LWW] Document ${payload.filter._id} not found locally. Inserting replicated version.`);
498
- await pushDataUnsecure([payload.data], modelName, user, {});
499
- } else if (docToUpdate._lastModifiedAt && new Date(docToUpdate._lastModifiedAt) >= incomingTimestamp) {
500
- // La version locale est plus récente ou identique. On ignore la réplication.
501
- logger.warn(`[Replication-LWW] Stale update for ${modelName}:${payload.filter._id} ignored. Local: ${docToUpdate._lastModifiedAt.toISOString()}, Incoming: ${incomingTimestamp.toISOString()}`);
502
- } else {
503
- // La version entrante est plus récente. On applique la mise à jour.
504
- // On utilise une opération de bas niveau pour éviter de redéclancher toute la logique de editData.
505
- await collection.replaceOne(payload.filter, payload.data);
491
+ case 'update': {
492
+ const docToUpdate = await collection.findOne(payload.filter);
493
+ const incomingTimestamp = new Date(payload.data._lastModifiedAt);
494
+
495
+ if (!docToUpdate) {
496
+ logger.info(`[Replication-LWW] Document for update not found locally. Inserting replicated version.`);
497
+ await collection.insertOne(payload.data);
498
+ } else if (docToUpdate._lastModifiedAt && new Date(docToUpdate._lastModifiedAt) >= incomingTimestamp) {
499
+ logger.warn(`[Replication-LWW] Stale update for ${modelName}:${payload.filter._id} ignored. Local: ${docToUpdate._lastModifiedAt.toISOString()}, Incoming: ${incomingTimestamp.toISOString()}`);
500
+ } else {
501
+ await collection.replaceOne(payload.filter, payload.data);
502
+ }
503
+ break;
504
+ }
505
+ case 'delete':
506
+ // La suppression l'emporte toujours.
507
+ await deleteData(modelName, payload.ids, user, false, false);
508
+ break;
509
+ default:
510
+ logger.error(`[Replication] Unknown replication operation in batch: ${operation}`);
506
511
  }
507
- break;
508
- case 'delete':
509
- // --- LWW: Pour la suppression, on peut la considérer comme l'état final ---
510
- // Si un document a été supprimé sur le maître, il doit l'être partout,
511
- // même si une réplique a une version "plus récente" (ce qui serait un état incohérent).
512
- // La suppression l'emporte.
513
- await deleteData(modelName, payload.ids, user, false, false);
514
- break;
515
- default:
516
- throw new Error(`Unknown replication operation: ${operation}`);
512
+ } catch (error) {
513
+ logger.error(`[Replication] Failed to apply one job from batch: ${operation} on ${modelName}. Error: ${error.message}`);
514
+ // On continue avec les autres opérations du lot.
517
515
  }
518
- res.status(200).json({ success: true });
519
- } catch (error) {
520
- logger.error(`[Replication] Failed to apply replication job: ${error.message}`, error.stack);
521
- res.status(500).json({ success: false, error: error.message });
522
516
  }
517
+
518
+ res.status(200).json({ success: true, message: 'Batch processed.' });
523
519
  });
524
520
 
525
521
  // NOUVEL ENDPOINT : Invalidation de cache interne au cluster
@@ -807,6 +803,33 @@ export async function registerRoutes(defaultEngine){
807
803
  });
808
804
  });
809
805
 
806
+ engine.delete('/api/import/job/:jobId', [middlewareAuthenticator], async (req, res) => {
807
+ const { jobId } = req.params;
808
+ const user = req.me;
809
+
810
+ if (!isObjectId(jobId)) {
811
+ return res.status(400).json({ success: false, error: 'Invalid Job ID format.' });
812
+ }
813
+
814
+ try {
815
+ const importJobsCollection = getCollection('import_jobs');
816
+ const result = await importJobsCollection.deleteOne({
817
+ _id: new ObjectId(jobId),
818
+ userId: user.username // Security check: user can only delete their own jobs
819
+ });
820
+
821
+ if (result.deletedCount === 1) {
822
+ res.status(200).json({ success: true, message: 'Job deleted successfully.' });
823
+ } else {
824
+ res.status(404).json({ success: false, error: 'Job not found or you do not have permission to delete it.' });
825
+ }
826
+ } catch (error) {
827
+ logger.error(`[DELETE /api/import/job] Error deleting job ${jobId}:`, error);
828
+ res.status(500).json({ success: false, error: 'An internal server error occurred.' });
829
+ }
830
+ });
831
+
832
+
810
833
  engine.get('/api/alerts/subscribe', [middlewareAuthenticator], (req, res) => {
811
834
  const user = req.me;
812
835
 
@@ -833,9 +856,9 @@ export async function registerRoutes(defaultEngine){
833
856
  engine.post('/api/data/import', [middlewareAuthenticator, userInitiator, ...userMiddlewares, setTimeoutMiddleware(60000)], async (req, res) => {
834
857
  // ... (vérifications de permissions existantes) ...
835
858
  const result = await importData(req.fields, req.files, req.me);
836
- if( result.success ){
837
- res.status(202).json(result);
838
- }else{
859
+ if (result.success) {
860
+ res.status(200).json(result); // Changed from 202 to 200
861
+ } else {
839
862
  res.status(500).json(result);
840
863
  }
841
864
  });
@@ -5,7 +5,7 @@ import {ObjectId} from "mongodb";
5
5
  import {getSmtpConfig} from "../user.js";
6
6
  import {runScheduledJobWithDbLock, substituteVariables} from "../workflow.js";
7
7
  import i18n from "../../i18n.js";
8
- import {sendEmail} from "../../email.js";
8
+ import {changeLanguage, sendEmail} from "../../email.js";
9
9
  import {sendSseToUser} from "./data.routes.js";
10
10
 
11
11
  import {searchData} from "./data.operations.js";
@@ -101,6 +101,7 @@ export async function runStatefulAlertJob(alertId) {
101
101
  });
102
102
  }
103
103
 
104
+ await changeLanguage(userLang);
104
105
  await sendEmail(
105
106
  user.email,
106
107
  {