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.
- package/README.md +160 -160
- package/client/package-lock.json +8430 -8430
- package/client/src/APIInfo.jsx +1 -1
- package/client/src/App.jsx +2 -1
- package/client/src/App.scss +1635 -1626
- package/client/src/AssistantChat.jsx +1 -0
- package/client/src/CalendarView.jsx +1 -0
- package/client/src/ContentView.jsx +3 -3
- package/client/src/Dashboard.jsx +5 -2
- package/client/src/DashboardChart.jsx +1 -0
- package/client/src/DashboardFlexViewItem.jsx +1 -0
- package/client/src/DashboardHtmlViewItem.jsx +1 -0
- package/client/src/DashboardView.jsx +2 -0
- package/client/src/DataEditor.jsx +0 -1
- package/client/src/DataImporter.jsx +489 -468
- package/client/src/DataLayout.jsx +6 -3
- package/client/src/DataTable.jsx +4 -3
- package/client/src/Dialog.jsx +92 -90
- package/client/src/Dialog.scss +122 -116
- package/client/src/DisplayFlexNodeRenderer.jsx +1 -0
- package/client/src/FlexBuilderControls.jsx +1 -0
- package/client/src/FlexBuilderPreview.jsx +1 -1
- package/client/src/FlexNode.jsx +1 -1
- package/client/src/HistoryDialog.jsx +3 -2
- package/client/src/KPIWidget.jsx +1 -1
- package/client/src/KanbanView.jsx +1 -0
- package/client/src/ModelCreator.jsx +4 -0
- package/client/src/ModelList.jsx +1 -0
- package/client/src/PackGallery.jsx +5 -4
- package/client/src/RTETrans.jsx +1 -1
- package/client/src/RelationField.jsx +2 -0
- package/client/src/RelationSelectorWidget.jsx +2 -0
- package/client/src/RelationValue.jsx +1 -0
- package/client/src/RestoreDialog.jsx +1 -0
- package/client/src/WorkflowEditor.jsx +3 -0
- package/client/src/contexts/CommandContext.jsx +2 -1
- package/client/src/contexts/ModelContext.jsx +3 -3
- package/client/src/hooks/data.js +1 -0
- package/client/src/hooks/useValidation.js +1 -0
- package/client/src/translations.js +24 -24
- package/doc/AI-assistance.md +87 -63
- package/doc/Concepts.md +122 -0
- package/doc/Custom-Endpoints.md +31 -0
- package/doc/Event-system.md +13 -14
- package/doc/Home.md +33 -0
- package/doc/Modules.md +83 -0
- package/doc/Packs-gallery.md +8 -23
- package/doc/Workflows.md +32 -0
- package/doc/automation-workflows.md +141 -102
- package/doc/dashboards-kpis-charts.md +47 -49
- package/doc/data-management.md +126 -120
- package/doc/data-models.md +68 -75
- package/doc/roles-permissions.md +144 -43
- package/doc/sharding-replication.md +158 -0
- package/doc/users.md +54 -30
- package/package.json +1 -1
- package/server.js +37 -37
- package/src/constants.js +7 -8
- package/src/data.js +521 -520
- package/src/email.js +157 -154
- package/src/engine.js +117 -7
- package/src/gameObject.js +6 -0
- package/src/i18n.js +0 -1
- package/src/modules/auth-google/index.js +53 -50
- package/src/modules/bucket.js +346 -339
- package/src/modules/data/data.backup.js +400 -376
- package/src/modules/data/data.cluster.js +410 -42
- package/src/modules/data/data.operations.js +3666 -3635
- package/src/modules/data/data.replication.js +106 -64
- package/src/modules/data/data.routes.js +87 -64
- package/src/modules/data/data.scheduling.js +2 -1
- package/src/modules/file.js +248 -247
- package/src/modules/user.js +11 -1
- package/src/sso.js +91 -25
- package/test/cluster.test.js +221 -0
- package/test/core.test.js +0 -2
- package/test/replication.test.js +163 -0
- 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 {
|
|
2
|
+
import { getReplicaNodesForUser } from './data.cluster.js';
|
|
3
|
+
import process from "node:process";
|
|
5
4
|
|
|
6
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
|
|
22
|
-
|
|
61
|
+
// D'abord, on essaie de vider les anciennes tâches en échec
|
|
62
|
+
await retryFailedBatches();
|
|
23
63
|
|
|
24
|
-
if (
|
|
25
|
-
return;
|
|
64
|
+
if (replicationQueue.length === 0) {
|
|
65
|
+
return;
|
|
26
66
|
}
|
|
27
67
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
const
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
|
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 {
|
|
464
|
-
|
|
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
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
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
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
// La
|
|
504
|
-
|
|
505
|
-
|
|
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
|
-
|
|
508
|
-
|
|
509
|
-
//
|
|
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(
|
|
837
|
-
res.status(
|
|
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
|
{
|