data-primals-engine 1.4.1 → 1.4.2
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 +23 -19
- package/client/src/AssistantChat.jsx +48 -5
- package/client/src/AssistantChat.scss +0 -1
- package/client/src/ChartConfigModal.jsx +34 -9
- package/client/src/Dashboard.jsx +396 -349
- package/client/src/DashboardChart.jsx +91 -12
- package/client/src/DataTable.jsx +817 -807
- package/client/src/Field.jsx +1784 -1782
- package/client/src/contexts/UIContext.jsx +5 -2
- package/client/src/translations.js +16818 -16750
- package/package.json +10 -2
- package/src/config.js +2 -2
- package/src/core.js +21 -3
- package/src/engine.js +2 -1
- package/src/events.js +4 -3
- package/src/middlewares/middleware-mongodb.js +3 -1
- package/src/modules/assistant/assistant.js +131 -42
- package/src/modules/bucket.js +3 -2
- package/src/modules/data/data.backup.js +374 -0
- package/src/modules/data/data.history.js +11 -8
- package/src/modules/data/data.js +190 -4551
- package/src/modules/data/data.operations.js +2790 -0
- package/src/modules/data/data.relations.js +687 -0
- package/src/modules/data/data.routes.js +132 -38
- package/src/modules/data/data.scheduling.js +274 -0
- package/src/modules/data/data.validation.js +245 -0
- package/src/modules/data/index.js +29 -1
- package/src/modules/user.js +2 -1
- package/src/modules/workflow.js +3 -2
- package/src/services/stripe.js +3 -2
|
@@ -1,56 +1,48 @@
|
|
|
1
1
|
import {Logger} from "../../gameObject.js";
|
|
2
|
-
import {
|
|
2
|
+
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
6
|
import {
|
|
7
|
-
install,
|
|
7
|
+
install,
|
|
8
|
+
maxBytesPerSecondThrottleData,
|
|
8
9
|
maxMagnetsDataPerModel,
|
|
9
10
|
maxMagnetsModels,
|
|
10
11
|
maxModelsPerUser
|
|
11
12
|
} from "../../constants.js";
|
|
12
|
-
import {
|
|
13
|
-
|
|
14
|
-
getCollection,
|
|
15
|
-
getCollectionForUser,
|
|
16
|
-
isObjectId,
|
|
17
|
-
modelsCollection
|
|
18
|
-
} from "../mongodb.js";
|
|
19
|
-
import {uuidv4} from "../../core.js";
|
|
13
|
+
import {datasCollection, getCollection, getCollectionForUser, isObjectId, modelsCollection} from "../mongodb.js";
|
|
14
|
+
import {safeAssignObject, uuidv4} from "../../core.js";
|
|
20
15
|
import {Event} from "../../events.js";
|
|
21
16
|
import fs from "node:fs";
|
|
22
17
|
import i18n from "../../i18n.js";
|
|
23
|
-
import {
|
|
24
|
-
triggerWorkflows
|
|
25
|
-
} from "../workflow.js";
|
|
18
|
+
import {executeSafeJavascript, triggerWorkflows} from "../workflow.js";
|
|
26
19
|
import {openaiJobModel} from "../../openai.jobs.js";
|
|
27
|
-
import {fileURLToPath} from 'url';
|
|
28
|
-
import {removeFile} from "../file.js";
|
|
29
20
|
import {getS3Stream, getUserS3Config} from "../bucket.js";
|
|
30
|
-
import {
|
|
31
|
-
generateLimiter,
|
|
32
|
-
hasPermission,
|
|
33
|
-
middlewareAuthenticator,
|
|
34
|
-
userInitiator
|
|
35
|
-
} from "../user.js";
|
|
21
|
+
import {generateLimiter, hasPermission, middlewareAuthenticator, userInitiator} from "../user.js";
|
|
36
22
|
import {assistantGlobalLimiter} from "../assistant/assistant.js";
|
|
37
23
|
import {Config} from "../../config.js";
|
|
38
24
|
import {processFilterPlaceholders} from "../../../client/src/filter.js";
|
|
39
25
|
import {tutorialsConfig} from "../../../client/src/tutorials.js";
|
|
40
|
-
import {
|
|
41
|
-
cancelAlerts, deleteData,
|
|
42
|
-
dumpUserData,
|
|
43
|
-
editData, editModel, exportData,
|
|
44
|
-
getModel, getResource,
|
|
45
|
-
handleCustomEndpointRequest,
|
|
46
|
-
handleDemoInitialization, importData, insertData, installPack, loadFromDump, middlewareEndpointAuthenticator,
|
|
47
|
-
patchData, searchData, validateModelStructure
|
|
48
|
-
} from "./data.js";
|
|
26
|
+
import {getResource, handleDemoInitialization} from "./data.js";
|
|
49
27
|
import process from "node:process";
|
|
50
28
|
import {throttleMiddleware} from "../../middlewares/throttle.js";
|
|
51
|
-
import {importJobs, modelsCache} from "./data.core.js";
|
|
52
|
-
|
|
53
|
-
|
|
29
|
+
import {importJobs, modelsCache, runImportExportWorker} from "./data.core.js";
|
|
30
|
+
import {validateModelStructure} from "./data.validation.js";
|
|
31
|
+
import {
|
|
32
|
+
deleteData,
|
|
33
|
+
editData,
|
|
34
|
+
editModel,
|
|
35
|
+
exportData,
|
|
36
|
+
getModel,
|
|
37
|
+
importData,
|
|
38
|
+
insertData,
|
|
39
|
+
installPack,
|
|
40
|
+
patchData,
|
|
41
|
+
searchData
|
|
42
|
+
} from "./data.operations.js";
|
|
43
|
+
import {dumpUserData, loadFromDump} from "./data.backup.js";
|
|
44
|
+
|
|
45
|
+
let logger, engine;
|
|
54
46
|
|
|
55
47
|
const sseConnections = new Map();
|
|
56
48
|
|
|
@@ -155,12 +147,115 @@ export async function sendSseToUser(username, data) {
|
|
|
155
147
|
}
|
|
156
148
|
|
|
157
149
|
|
|
158
|
-
export async function
|
|
150
|
+
export async function handleCustomEndpointRequest(req, res) {
|
|
151
|
+
const endpointDef = req.endpointDef;
|
|
159
152
|
|
|
160
|
-
|
|
153
|
+
try {
|
|
154
|
+
let executionUser = null;
|
|
155
|
+
|
|
156
|
+
// 1. Déterminer le contexte utilisateur pour l'exécution
|
|
157
|
+
if (endpointDef.isPublic) {
|
|
158
|
+
// Pour les endpoints publics, on exécute le script en tant que propriétaire de l'endpoint.
|
|
159
|
+
if (!endpointDef._user) {
|
|
160
|
+
logger.error(`[Endpoint] Misconfiguration: Public endpoint '${endpointDef.name}' (ID: ${endpointDef._id}) has no owner.`);
|
|
161
|
+
return res.status(500).json({success: false, message: 'Endpoint misconfigured: owner missing.'});
|
|
162
|
+
}
|
|
163
|
+
executionUser = await engine.userProvider.findUserByUsername(endpointDef._user);
|
|
164
|
+
if (!executionUser) {
|
|
165
|
+
logger.error(`[Endpoint] Execution failed: Owner '${endpointDef._user}' for public endpoint '${endpointDef.name}' not found.`);
|
|
166
|
+
return res.status(500).json({success: false, message: 'Endpoint owner not found.'});
|
|
167
|
+
}
|
|
168
|
+
logger.info(`[Endpoint] Public endpoint '${endpointDef.name}' running as owner '${executionUser.username}'.`);
|
|
169
|
+
} else {
|
|
170
|
+
// Pour les endpoints privés, l'utilisateur a déjà été authentifié par le middleware.
|
|
171
|
+
// req.me est garanti d'exister ici.
|
|
172
|
+
executionUser = req.me;
|
|
173
|
+
logger.info(`[Endpoint] Private endpoint '${endpointDef.name}' running as authenticated user '${executionUser.username}'.`);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// 2. Préparer le contexte pour le script
|
|
177
|
+
const contextData = {
|
|
178
|
+
request: {
|
|
179
|
+
// MODIFICATION: Utiliser req.body si disponible (pour les requêtes JSON comme les webhooks Stripe),
|
|
180
|
+
// sinon, utiliser req.fields (pour les données de formulaire).
|
|
181
|
+
body: (req.body && Object.keys(req.body).length > 0) ? req.body : (req.fields || {}),
|
|
182
|
+
query: req.query,
|
|
183
|
+
params: req.params,
|
|
184
|
+
headers: req.headers
|
|
185
|
+
}
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
// 3. Exécuter le code de l'endpoint
|
|
189
|
+
const result = await executeSafeJavascript(
|
|
190
|
+
{script: endpointDef.code},
|
|
191
|
+
contextData,
|
|
192
|
+
executionUser // Use the determined user for execution
|
|
193
|
+
);
|
|
194
|
+
|
|
195
|
+
// 4. Envoyer la réponse
|
|
196
|
+
if (result.success) {
|
|
197
|
+
res.status(200).json(result.data);
|
|
198
|
+
} else {
|
|
199
|
+
logger.error(`[Endpoint] Execution failed for '${endpointDef.name}'. Error: ${result.message}`);
|
|
200
|
+
const responseError = {
|
|
201
|
+
success: false,
|
|
202
|
+
message: 'Endpoint script execution failed.',
|
|
203
|
+
details: result.message,
|
|
204
|
+
logs: result.logs
|
|
205
|
+
};
|
|
206
|
+
res.status(500).json(responseError);
|
|
207
|
+
}
|
|
161
208
|
|
|
209
|
+
} catch (error) {
|
|
210
|
+
logger.error(`[Endpoint] Critical error handling request for path '${endpointDef.path}': ${error.message}`, error.stack);
|
|
211
|
+
res.status(500).json({success: false, message: 'An internal server error occurred.'});
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export async function middlewareEndpointAuthenticator(req, res, next) {
|
|
216
|
+
const {path} = req.params;
|
|
217
|
+
const method = req.method.toUpperCase();
|
|
218
|
+
const user = await engine.userProvider.findUserByUsername(req.query._user || req.params.user || req.me.username);
|
|
219
|
+
const datasCollection = await getCollectionForUser(user);
|
|
220
|
+
|
|
221
|
+
try {
|
|
222
|
+
const endpointDef = await datasCollection.findOne({
|
|
223
|
+
_model: 'endpoint',
|
|
224
|
+
path: path,
|
|
225
|
+
method: method,
|
|
226
|
+
isActive: true
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
if (!endpointDef) {
|
|
230
|
+
return res.status(404).json({success: false, message: 'Endpoint not found.'});
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Attacher la définition à la requête pour que le handler suivant puisse l'utiliser
|
|
234
|
+
req.endpointDef = endpointDef;
|
|
235
|
+
|
|
236
|
+
// Si l'endpoint n'est PAS public, on exécute le vrai middleware d'authentification
|
|
237
|
+
if (!endpointDef.isPublic) {
|
|
238
|
+
// On "chaîne" vers le middleware authenticator standard.
|
|
239
|
+
// Il se chargera de vérifier le token et de renvoyer une 401 si nécessaire.
|
|
240
|
+
return middlewareAuthenticator(req, res, next);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Si l'endpoint EST public, on passe simplement à la suite.
|
|
244
|
+
next();
|
|
245
|
+
|
|
246
|
+
} catch (error) {
|
|
247
|
+
logger.error(`[EndpointAuth] Critical error: ${error.message}`, error.stack);
|
|
248
|
+
res.status(500).json({success: false, message: 'Internal server error during endpoint authentication.'});
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export async function registerRoutes(defaultEngine){
|
|
253
|
+
|
|
254
|
+
engine = defaultEngine;
|
|
162
255
|
logger = engine.getComponent(Logger);
|
|
163
256
|
|
|
257
|
+
let userMiddlewares = await engine.userProvider.getMiddlewares();
|
|
258
|
+
|
|
164
259
|
engine.all('/api/actions/:user/:path', [middlewareEndpointAuthenticator, userInitiator], handleCustomEndpointRequest);
|
|
165
260
|
engine.all('/api/actions/:path', [middlewareAuthenticator, middlewareEndpointAuthenticator, userInitiator], handleCustomEndpointRequest);
|
|
166
261
|
engine.post('/api/demo/initialize', [middlewareAuthenticator, userInitiator], handleDemoInitialization);
|
|
@@ -230,6 +325,7 @@ export async function registerRoutes(engine){
|
|
|
230
325
|
for (const field of model.fields) {
|
|
231
326
|
if (field.type === 'relation' && newDoc[field.name]) {
|
|
232
327
|
if (Array.isArray(newDoc[field.name])) {
|
|
328
|
+
safeAssignObject()
|
|
233
329
|
newDoc[field.name] = newDoc[field.name]
|
|
234
330
|
.map(oldId => idMap[oldId.toString()]?.toString())
|
|
235
331
|
.filter(Boolean);
|
|
@@ -1319,10 +1415,8 @@ export async function registerRoutes(engine){
|
|
|
1319
1415
|
|
|
1320
1416
|
// --- Execute Aggregation ---
|
|
1321
1417
|
const results = await collection.aggregate(pipeline).toArray();
|
|
1322
|
-
const finalResults = results;
|
|
1323
|
-
|
|
1324
1418
|
// --- Send Response ---
|
|
1325
|
-
res.json(
|
|
1419
|
+
res.json(results);
|
|
1326
1420
|
|
|
1327
1421
|
} catch (error) {
|
|
1328
1422
|
// ... (gestion des erreurs existante, inchangée) ...
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
import {getCollection} from "../mongodb.js";
|
|
2
|
+
import schedule from "node-schedule";
|
|
3
|
+
import {maxAlertsPerUser} from "../../constants.js";
|
|
4
|
+
import {ObjectId} from "mongodb";
|
|
5
|
+
import {getSmtpConfig} from "../user.js";
|
|
6
|
+
import {runScheduledJobWithDbLock, substituteVariables} from "../workflow.js";
|
|
7
|
+
import i18n from "../../i18n.js";
|
|
8
|
+
import {sendEmail} from "../../email.js";
|
|
9
|
+
import {sendSseToUser} from "./data.routes.js";
|
|
10
|
+
|
|
11
|
+
import {searchData} from "./data.operations.js";
|
|
12
|
+
import {Logger} from "../../gameObject.js";
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
let engine, logger;
|
|
16
|
+
export function onInit(defaultEngine) {
|
|
17
|
+
engine = defaultEngine;
|
|
18
|
+
logger = engine.getComponent(Logger);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const cancelAlerts = async (user) => {
|
|
22
|
+
|
|
23
|
+
const datasCollection = getCollection('datas'); // Alerts are in the global collection
|
|
24
|
+
|
|
25
|
+
// 1. Fetch the latest state of the alert
|
|
26
|
+
const alertDocs = await datasCollection.find({_user: user.username, _model: 'alert'}).toArray();
|
|
27
|
+
alertDocs.forEach(doc => {
|
|
28
|
+
const jobId = `alert_${doc._id}`;
|
|
29
|
+
schedule.scheduledJobs[jobId]?.cancel();
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Executes a stateful alert job. It checks if a notification for an alert
|
|
35
|
+
* has already been sent and only sends one if the condition is met and no
|
|
36
|
+
* recent notification exists. The state is tracked via the 'lastNotifiedAt'
|
|
37
|
+
* field in the alert document.
|
|
38
|
+
* @param {string|ObjectId} alertId - The ID of the alert to process.
|
|
39
|
+
*/
|
|
40
|
+
export async function runStatefulAlertJob(alertId) {
|
|
41
|
+
const jobId = `alert_${alertId}`;
|
|
42
|
+
logger.info(`[Scheduled Job] Cron triggered for stateful alert job ${jobId}.`);
|
|
43
|
+
|
|
44
|
+
try {
|
|
45
|
+
const datasCollection = getCollection('datas'); // Alerts are in the global collection
|
|
46
|
+
|
|
47
|
+
// 1. Fetch the latest state of the alert
|
|
48
|
+
const alertDoc = await datasCollection.findOne({_id: new ObjectId(alertId)});
|
|
49
|
+
|
|
50
|
+
// Safety checks
|
|
51
|
+
if (!alertDoc) {
|
|
52
|
+
logger.warn(`[Scheduled Job] Alert ${alertId} not found. Cancelling job.`);
|
|
53
|
+
schedule.scheduledJobs[jobId]?.cancel();
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (!alertDoc.isActive || !alertDoc.frequency) {
|
|
58
|
+
logger.info(`[Scheduled Job] Alert ${alertId} is no longer active or has no frequency. Cancelling job.`);
|
|
59
|
+
schedule.scheduledJobs[jobId]?.cancel();
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// 2. Check if a notification has already been sent for this state
|
|
64
|
+
if (alertDoc.lastNotifiedAt) {
|
|
65
|
+
logger.debug(`[Scheduled Job] Notification for alert ${alertId} has already been sent. Skipping evaluation.`);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// 3. Evaluate the trigger condition
|
|
70
|
+
const apiFilter = (alertDoc.triggerCondition);
|
|
71
|
+
const {count} = await searchData({
|
|
72
|
+
model: alertDoc.targetModel,
|
|
73
|
+
filter: apiFilter,
|
|
74
|
+
limit: 1
|
|
75
|
+
}, {username: alertDoc._user});
|
|
76
|
+
|
|
77
|
+
// 4. If condition is met, send notification and update state
|
|
78
|
+
if (count > 0) {
|
|
79
|
+
logger.info(`[Scheduled Job] Condition met for alert ${alertDoc.name} (ID: ${alertId}). Sending notification and updating state.`);
|
|
80
|
+
|
|
81
|
+
let emailSent = false;
|
|
82
|
+
try {
|
|
83
|
+
const user = await engine.userProvider.findUserByUsername(alertDoc._user);
|
|
84
|
+
if (user && user.email) {
|
|
85
|
+
const smtpConfig = await getSmtpConfig(user);
|
|
86
|
+
if (alertDoc.sendEmail && smtpConfig) {
|
|
87
|
+
const userLang = user.lang || 'en';
|
|
88
|
+
let emailContent, msg;
|
|
89
|
+
if (alertDoc.message) {
|
|
90
|
+
if (alertDoc.message[userLang])
|
|
91
|
+
msg = alertDoc.message[userLang];
|
|
92
|
+
else
|
|
93
|
+
msg = alertDoc.message[Object.keys(alertDoc.message)[0]];
|
|
94
|
+
emailContent = await substituteVariables(msg, {count, alert: alertDoc});
|
|
95
|
+
} else {
|
|
96
|
+
// Sinon, utiliser le message par défaut
|
|
97
|
+
emailContent = i18n.t('alert.email.content', `L'alerte '${alertDoc.name}' s'est déclenchée. ${count} élément(s) correspondent à votre condition.`, {
|
|
98
|
+
name: alertDoc.name,
|
|
99
|
+
count: count
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
await sendEmail(
|
|
104
|
+
user.email,
|
|
105
|
+
{
|
|
106
|
+
title: i18n.t('alert.email.title', `Alerte: ${alertDoc.name}`),
|
|
107
|
+
content: emailContent
|
|
108
|
+
},
|
|
109
|
+
smtpConfig,
|
|
110
|
+
userLang
|
|
111
|
+
);
|
|
112
|
+
emailSent = true;
|
|
113
|
+
logger.info(`[Scheduled Job] Email notification sent for alert ${alertId} to ${user.email}.`);
|
|
114
|
+
} else if (alertDoc.sendEmail) {
|
|
115
|
+
logger.warn(`[Scheduled Job] Could not send email for alert ${alertId}. SMTP config is missing or incomplete for user ${user.username}.`);
|
|
116
|
+
}
|
|
117
|
+
} else {
|
|
118
|
+
logger.warn(`[Scheduled Job] Could not send email for alert ${alertId}. User ${alertDoc._user} not found or has no email address.`);
|
|
119
|
+
}
|
|
120
|
+
} catch (emailError) {
|
|
121
|
+
logger.error(`[Scheduled Job] Failed to send email for alert ${alertId}:`, emailError);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Send notification
|
|
125
|
+
const alertPayload = {
|
|
126
|
+
type: 'cron_alert',
|
|
127
|
+
triggerId: alertDoc._id.toString(),
|
|
128
|
+
triggerName: alertDoc.name,
|
|
129
|
+
timestamp: new Date().toISOString(),
|
|
130
|
+
message: `Alerte '${alertDoc.name}': ${count} élément(s) correspondent à votre condition.`,
|
|
131
|
+
emailSent
|
|
132
|
+
};
|
|
133
|
+
sendSseToUser(alertDoc._user, alertPayload);
|
|
134
|
+
|
|
135
|
+
// Update state in DB to prevent re-notification
|
|
136
|
+
await datasCollection.updateOne(
|
|
137
|
+
{_id: new ObjectId(alertId)},
|
|
138
|
+
{$set: {lastNotifiedAt: new Date()}}
|
|
139
|
+
);
|
|
140
|
+
} else {
|
|
141
|
+
logger.debug(`[Scheduled Job] Condition not met for alert ${alertId}. No notification sent.`);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
} catch (error) {
|
|
145
|
+
logger.error(`[Scheduled Job] Error processing stateful alert job ${jobId}:`, error);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export async function scheduleAlerts() {
|
|
150
|
+
logger.info('[scheduleAlerts] Starting scheduling of oldest active alerts per user...');
|
|
151
|
+
try {
|
|
152
|
+
const datasCollection = getCollection('datas');
|
|
153
|
+
|
|
154
|
+
// --- NOUVELLE LOGIQUE AVEC AGRÉGATION ---
|
|
155
|
+
const aggregationPipeline = [
|
|
156
|
+
// 1. Match: Ne sélectionner que les alertes actives avec une fréquence définie.
|
|
157
|
+
{
|
|
158
|
+
$match: {
|
|
159
|
+
_model: 'alert',
|
|
160
|
+
isActive: true,
|
|
161
|
+
frequency: {$exists: true, $ne: ""}
|
|
162
|
+
}
|
|
163
|
+
},
|
|
164
|
+
// 2. Sort: Trier les alertes par utilisateur, puis par date de création (les plus anciennes en premier).
|
|
165
|
+
// L'ObjectId contient un timestamp, donc trier par _id est équivalent à trier par date de création.
|
|
166
|
+
{
|
|
167
|
+
$sort: {
|
|
168
|
+
_user: 1, // Grouper par utilisateur
|
|
169
|
+
_id: 1 // Trier par date de création (ascendant)
|
|
170
|
+
}
|
|
171
|
+
},
|
|
172
|
+
// 3. Group: Regrouper toutes les alertes par utilisateur dans un tableau.
|
|
173
|
+
{
|
|
174
|
+
$group: {
|
|
175
|
+
_id: "$_user", // La clé de groupement est le nom de l'utilisateur
|
|
176
|
+
alerts: {$push: "$$ROOT"} // $$ROOT pousse le document entier dans le tableau 'alerts'
|
|
177
|
+
}
|
|
178
|
+
},
|
|
179
|
+
// 4. Project (Slice): Pour chaque utilisateur, ne garder que les X premières alertes du tableau trié.
|
|
180
|
+
{
|
|
181
|
+
$project: {
|
|
182
|
+
oldestAlerts: {$slice: ["$alerts", maxAlertsPerUser]}
|
|
183
|
+
}
|
|
184
|
+
},
|
|
185
|
+
// 5. Unwind: Déconstruire le tableau 'oldestAlerts' pour obtenir un flux de documents, un par alerte.
|
|
186
|
+
{
|
|
187
|
+
$unwind: "$oldestAlerts"
|
|
188
|
+
},
|
|
189
|
+
// 6. ReplaceRoot: Remplacer la structure du document par le contenu de l'alerte elle-même.
|
|
190
|
+
{
|
|
191
|
+
$replaceRoot: {newRoot: "$oldestAlerts"}
|
|
192
|
+
}
|
|
193
|
+
];
|
|
194
|
+
|
|
195
|
+
const alertsToSchedule = await datasCollection.aggregate(aggregationPipeline).toArray();
|
|
196
|
+
// --- FIN DE LA NOUVELLE LOGIQUE ---
|
|
197
|
+
|
|
198
|
+
logger.info(`[scheduleAlerts] Found ${alertsToSchedule.length} oldest active alerts across all users to schedule.`);
|
|
199
|
+
|
|
200
|
+
for (const alertDoc of alertsToSchedule) {
|
|
201
|
+
const jobId = `alert_${alertDoc._id}`;
|
|
202
|
+
try {
|
|
203
|
+
// Annuler une tâche existante pour la même alerte si elle existe (logique de sécurité)
|
|
204
|
+
if (schedule.scheduledJobs[jobId]) {
|
|
205
|
+
schedule.scheduledJobs[jobId].cancel();
|
|
206
|
+
}
|
|
207
|
+
// Planifier la tâche avec la nouvelle logique stateful
|
|
208
|
+
schedule.scheduleJob(jobId, alertDoc.frequency, () => runStatefulAlertJob(alertDoc._id));
|
|
209
|
+
} catch (scheduleError) {
|
|
210
|
+
logger.error(`[scheduleAlerts] Failed to schedule job ${jobId} for alert ${alertDoc._id}. Error: ${scheduleError.message}`);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
logger.info('[scheduleAlerts] Finished scheduling alerts.');
|
|
214
|
+
} catch (error) {
|
|
215
|
+
logger.error('[scheduleAlerts] A critical error occurred during alert scheduling:', error);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Applique un masque et des valeurs par défaut à une expression cron.
|
|
222
|
+
* @param {string} cronString - L'expression cron d'entrbooléens. `false` pour désactiver et appliquer la valeur par défaut.
|
|
223
|
+
* @param {string[]} defaults - Un tableau de 5 chaînes de caractères pour les valeurs par défaut.
|
|
224
|
+
* @returns {string} - L'expression cron modifiée.
|
|
225
|
+
*/
|
|
226
|
+
export function applyCronMask(cronString, mask, defaults) {
|
|
227
|
+
if (typeof cronString !== 'string' || cronString.trim() === '' || !mask || !defaults) {
|
|
228
|
+
return cronString;
|
|
229
|
+
}
|
|
230
|
+
const parts = cronString.split(' ');
|
|
231
|
+
if (parts.length < 5) {
|
|
232
|
+
return cronString; // Laisse la validation standard gérer les formats incorrects
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const newParts = parts.slice(0, 5).map((part, index) => {
|
|
236
|
+
// Si le masque à cet index est `false`, on force la valeur par défaut.
|
|
237
|
+
if (mask[index] === false) {
|
|
238
|
+
return defaults[index];
|
|
239
|
+
}
|
|
240
|
+
// Sinon, on garde la valeur de l'utilisateur.
|
|
241
|
+
return part;
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
return newParts.join(' ');
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export async function handleScheduledJobs(modelName, existingDocs, collection, updateData) {
|
|
248
|
+
for (const doc of existingDocs) {
|
|
249
|
+
const jobId = `${modelName}_${doc._id}`;
|
|
250
|
+
const existingJob = schedule.scheduledJobs[jobId];
|
|
251
|
+
if (existingJob) existingJob.cancel();
|
|
252
|
+
|
|
253
|
+
const updatedDoc = {...doc, ...updateData};
|
|
254
|
+
if (modelName === 'workflowTrigger' && updatedDoc.isActive && updatedDoc.cronExpression) {
|
|
255
|
+
schedule.scheduleJob(jobId, updatedDoc.cronExpression, async () => {
|
|
256
|
+
logger.info(`[Scheduled Job] Cron triggered for job ${jobId}`);
|
|
257
|
+
await runScheduledJobWithDbLock(jobId, async () => {
|
|
258
|
+
sendSseToUser(updatedDoc._user, {
|
|
259
|
+
type: 'cron_alert',
|
|
260
|
+
triggerId: updatedDoc._id.toString(),
|
|
261
|
+
triggerName: updatedDoc.name,
|
|
262
|
+
timestamp: new Date().toISOString(),
|
|
263
|
+
message: `L'alerte planifiée '${updatedDoc.name || 'Sans nom'}' a été déclenchée.`
|
|
264
|
+
});
|
|
265
|
+
}, updatedDoc.lockDurationMinutes || 5);
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
if (modelName === 'alert' && updatedDoc.isActive && updatedDoc.frequency) {
|
|
270
|
+
await collection.updateOne({_id: updatedDoc._id}, {$set: {lastNotifiedAt: null}});
|
|
271
|
+
schedule.scheduleJob(jobId, updatedDoc.frequency, () => runStatefulAlertJob(updatedDoc._id));
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|