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.
@@ -1,56 +1,48 @@
1
1
  import {Logger} from "../../gameObject.js";
2
- import {BSON, ObjectId} from "mongodb";
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, maxBytesPerSecondThrottleData,
7
+ install,
8
+ maxBytesPerSecondThrottleData,
8
9
  maxMagnetsDataPerModel,
9
10
  maxMagnetsModels,
10
11
  maxModelsPerUser
11
12
  } from "../../constants.js";
12
- import {
13
- datasCollection, filesCollection,
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
- let logger;
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 registerRoutes(engine){
150
+ export async function handleCustomEndpointRequest(req, res) {
151
+ const endpointDef = req.endpointDef;
159
152
 
160
- let userMiddlewares = await engine.userProvider.getMiddlewares();
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(finalResults);
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
+ }