data-primals-engine 1.4.1 → 1.4.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 +37 -22
- package/client/package-lock.json +33 -0
- package/client/package.json +1 -0
- package/client/src/App.scss +12 -4
- package/client/src/AssistantChat.jsx +48 -5
- package/client/src/AssistantChat.scss +0 -1
- package/client/src/ChartConfigModal.jsx +34 -9
- package/client/src/ConditionBuilder.jsx +1 -1
- package/client/src/ConditionBuilder2.jsx +2 -1
- package/client/src/Dashboard.jsx +396 -349
- package/client/src/DashboardChart.jsx +91 -12
- package/client/src/DataEditor.jsx +376 -368
- package/client/src/DataLayout.jsx +3 -2
- package/client/src/DataTable.jsx +60 -31
- package/client/src/Field.jsx +1788 -1782
- package/client/src/GeolocationField.jsx +94 -0
- package/client/src/ModelCreatorField.jsx +1 -0
- package/client/src/RelationField.jsx +2 -2
- package/client/src/constants.js +2 -2
- package/client/src/contexts/UIContext.jsx +5 -2
- package/client/src/filter.js +0 -155
- package/client/src/translations.js +16828 -16750
- package/package.json +10 -2
- package/src/config.js +2 -2
- package/src/constants.js +262 -4
- package/src/core.js +21 -3
- package/src/defaultModels.js +12 -12
- package/src/engine.js +7 -1
- package/src/events.js +4 -3
- package/src/filter.js +272 -260
- package/src/i18n.js +187 -177
- 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.core.js +2 -1
- package/src/modules/data/data.history.js +11 -8
- package/src/modules/data/data.js +190 -4551
- package/src/modules/data/data.operations.js +3000 -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 +248 -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
- package/swagger-en.yml +133 -0
- package/test/model.integration.test.js +377 -221
package/src/modules/data/data.js
CHANGED
|
@@ -1,475 +1,33 @@
|
|
|
1
1
|
import {Logger} from "../../gameObject.js";
|
|
2
|
-
import {BSON, ObjectId} from "mongodb";
|
|
3
|
-
import {promisify} from 'node:util';
|
|
4
|
-
import crypto from "node:crypto";
|
|
5
|
-
import {execFile} from 'node:child_process';
|
|
6
|
-
import sanitizeHtml from 'sanitize-html';
|
|
7
|
-
import * as tar from "tar";
|
|
8
|
-
import process from "node:process";
|
|
9
|
-
import {randomColor} from "randomcolor";
|
|
10
|
-
import cronstrue from 'cronstrue/i18n.js';
|
|
11
2
|
import {mkdir} from 'node:fs/promises';
|
|
12
3
|
import {onInit as historyInit} from "./data.history.js";
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
15
|
-
|
|
16
|
-
dbName,
|
|
17
|
-
install,
|
|
18
|
-
maxAlertsPerUser,
|
|
19
|
-
maxExportCount,
|
|
20
|
-
maxFileSize,
|
|
21
|
-
maxFilterDepth,
|
|
22
|
-
maxModelNameLength,
|
|
23
|
-
maxPasswordLength,
|
|
24
|
-
maxPostData,
|
|
25
|
-
maxRelationsPerData,
|
|
26
|
-
maxRequestData,
|
|
27
|
-
maxRichTextLength,
|
|
28
|
-
maxStringLength,
|
|
29
|
-
maxTotalDataPerUser,
|
|
30
|
-
megabytes,
|
|
31
|
-
optionsSanitizer,
|
|
32
|
-
searchRequestTimeout,
|
|
33
|
-
storageSafetyMargin
|
|
34
|
-
} from "../../constants.js";
|
|
35
|
-
import {
|
|
36
|
-
createCollection,
|
|
37
|
-
getCollection,
|
|
38
|
-
getCollectionForUser,
|
|
39
|
-
getUserCollectionName,
|
|
40
|
-
isObjectId,
|
|
41
|
-
modelsCollection,
|
|
42
|
-
packsCollection
|
|
43
|
-
} from "../mongodb.js";
|
|
44
|
-
import {dbUrl, MongoClient, MongoDatabase} from "../../engine.js";
|
|
4
|
+
import {isDemoUser, isLocalUser} from "../../data.js";
|
|
5
|
+
import {install, maxRequestData, storageSafetyMargin} from "../../constants.js";
|
|
6
|
+
import {createCollection, getCollection} from "../mongodb.js";
|
|
45
7
|
import path from "node:path";
|
|
46
|
-
import {
|
|
8
|
+
import {isGUID, sequential} from "../../core.js";
|
|
47
9
|
import {Event} from "../../events.js";
|
|
48
10
|
import fs from "node:fs";
|
|
49
11
|
import schedule from "node-schedule";
|
|
50
12
|
import {middleware} from "../../middlewares/middleware-mongodb.js";
|
|
51
13
|
import i18n from "../../i18n.js";
|
|
52
|
-
import {
|
|
53
|
-
executeSafeJavascript,
|
|
54
|
-
runScheduledJobWithDbLock,
|
|
55
|
-
scheduleWorkflowTriggers, substituteVariables,
|
|
56
|
-
triggerWorkflows
|
|
57
|
-
} from "../workflow.js";
|
|
58
|
-
import NodeCache from "node-cache";
|
|
59
|
-
import AWS from 'aws-sdk';
|
|
60
14
|
import checkDiskSpace from "check-disk-space";
|
|
61
|
-
import {
|
|
62
|
-
import {
|
|
63
|
-
import {calculateTotalUserStorageUsage, getSmtpConfig, hasPermission, middlewareAuthenticator} from "../user.js";
|
|
64
|
-
import {getAllPacks} from "../../packs.js";
|
|
65
|
-
import {Config} from "../../config.js";
|
|
15
|
+
import {removeFile} from "../file.js";
|
|
16
|
+
import {hasPermission} from "../user.js";
|
|
66
17
|
import {profiles} from "../../../client/src/constants.js";
|
|
67
|
-
import {registerRoutes
|
|
68
|
-
import {
|
|
69
|
-
import
|
|
70
|
-
import {
|
|
18
|
+
import {registerRoutes} from "./data.routes.js";
|
|
19
|
+
import {mongoDBWhitelist} from "./data.core.js";
|
|
20
|
+
import {onInit as relationsInit} from "./data.relations.js";
|
|
21
|
+
import {validateField, onInit as validationInit} from "./data.validation.js";
|
|
22
|
+
import {cancelAlerts, scheduleAlerts, onInit as scheduleInit} from "./data.scheduling.js";
|
|
23
|
+
import {deleteData, installPack, onInit as operationsInit} from "./data.operations.js";
|
|
24
|
+
import {jobDumpUserData, onInit as backupInit} from "./data.backup.js";
|
|
71
25
|
|
|
72
26
|
let engine;
|
|
73
27
|
let logger;
|
|
74
28
|
|
|
75
|
-
const delay = ms => new Promise(res => setTimeout(res, ms));
|
|
76
|
-
|
|
77
|
-
const getBackupDir = () => process.env.BACKUP_DIR || './backups'; // Répertoire de stockage des sauvegardes
|
|
78
|
-
const execFileAsync = promisify(execFile);
|
|
79
|
-
|
|
80
|
-
const IMPORT_CHUNK_SIZE = 100; // Nombre d'enregistrements à traiter par lot
|
|
81
|
-
const IMPORT_CHUNK_DELAY_MS = 1000; // Délai en millisecondes entre le traitement des lots
|
|
82
|
-
|
|
83
|
-
let depthFilter= 0;
|
|
84
|
-
|
|
85
29
|
const DATA_STORAGE_PATH = path.resolve('./');
|
|
86
30
|
|
|
87
|
-
// Création du cache avec des options configurables
|
|
88
|
-
const relationCache = new NodeCache({
|
|
89
|
-
stdTTL: 3600, // TTL par défaut de 1 heure (en secondes)
|
|
90
|
-
checkperiod: 600, // Vérification des éléments expirés toutes les 10 minutes
|
|
91
|
-
useClones: false // Pour des performances optimales avec des ObjectId
|
|
92
|
-
});
|
|
93
|
-
|
|
94
|
-
export const jobDumpUserData = async () => {
|
|
95
|
-
|
|
96
|
-
try {
|
|
97
|
-
|
|
98
|
-
const primalsDb = MongoClient.db("primals");
|
|
99
|
-
|
|
100
|
-
let usersCollection = primalsDb.collection("users");
|
|
101
|
-
const users = await usersCollection.find().toArray();
|
|
102
|
-
|
|
103
|
-
users.forEach((user) =>
|
|
104
|
-
{
|
|
105
|
-
if( isDemoUser(user) && Config.Get("useDemoAccounts"))
|
|
106
|
-
return;
|
|
107
|
-
try {
|
|
108
|
-
dumpUserData(user).catch(e => {
|
|
109
|
-
Event.Trigger("OnUserDataDumped", "event", "system", engine);
|
|
110
|
-
})
|
|
111
|
-
} catch (ignored) {
|
|
112
|
-
|
|
113
|
-
}
|
|
114
|
-
});
|
|
115
|
-
|
|
116
|
-
}catch (e) {
|
|
117
|
-
console.error(e);
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
export const dataTypes = {
|
|
122
|
-
object: {
|
|
123
|
-
validate: (value, field) => {
|
|
124
|
-
return value === null || isPlainObject(value);
|
|
125
|
-
}
|
|
126
|
-
},
|
|
127
|
-
model: {
|
|
128
|
-
validate: (value, field) => {
|
|
129
|
-
return value === null || typeof value === 'string' && value.length <= maxModelNameLength;
|
|
130
|
-
}
|
|
131
|
-
},
|
|
132
|
-
cronSchedule: {
|
|
133
|
-
validate: (value, field) => {
|
|
134
|
-
if( value === null )
|
|
135
|
-
return true;
|
|
136
|
-
try {
|
|
137
|
-
cronstrue.toString(value, { throwExceptionOnParseError: true });
|
|
138
|
-
return true;
|
|
139
|
-
} catch (e) {
|
|
140
|
-
return false;
|
|
141
|
-
}
|
|
142
|
-
},
|
|
143
|
-
filter: async (value, field)=>{
|
|
144
|
-
if( value === null )
|
|
145
|
-
return null;
|
|
146
|
-
if (field.cronMask && field.default) {
|
|
147
|
-
return applyCronMask(value, field.cronMask, field.default);
|
|
148
|
-
}
|
|
149
|
-
return value;
|
|
150
|
-
}
|
|
151
|
-
},
|
|
152
|
-
modelField: {
|
|
153
|
-
validate: (value, field) => {
|
|
154
|
-
return value === null || typeof value === 'object' && JSON.stringify(value).length <= maxModelNameLength + 100;
|
|
155
|
-
}
|
|
156
|
-
},
|
|
157
|
-
string: {
|
|
158
|
-
validate: (value, field) => {
|
|
159
|
-
const ml = Math.min(Math.max(field.maxlength, 0), maxStringLength);
|
|
160
|
-
return value === null || typeof value === 'string' && (!ml || value.length <= ml)
|
|
161
|
-
},
|
|
162
|
-
anonymize: anonymizeText
|
|
163
|
-
},
|
|
164
|
-
code: {
|
|
165
|
-
validate: (value, field) => {
|
|
166
|
-
return value === null || (field.language === 'json' && typeof(value) === 'object') || (typeof value === 'string' && (field.maxlength === undefined || field.maxlength <= 0 || value.length <= field.maxlength));
|
|
167
|
-
},
|
|
168
|
-
filter: async (value, field) => {
|
|
169
|
-
if( field.language === 'json')
|
|
170
|
-
{
|
|
171
|
-
if( typeof(value) === 'object')
|
|
172
|
-
return value;
|
|
173
|
-
else if( typeof(value) === 'string') {
|
|
174
|
-
try {
|
|
175
|
-
return JSON.parse(value);
|
|
176
|
-
} catch (e) {
|
|
177
|
-
return null;
|
|
178
|
-
}
|
|
179
|
-
}else{
|
|
180
|
-
return null;
|
|
181
|
-
}
|
|
182
|
-
}
|
|
183
|
-
return value;
|
|
184
|
-
},
|
|
185
|
-
anonymize: (str) => anonymizeText(typeof(str) ==='object' ? JSON.stringify(str): str)
|
|
186
|
-
},
|
|
187
|
-
richtext: {
|
|
188
|
-
validate: (value, field) => {
|
|
189
|
-
const ml = Math.min(Math.max(field.maxlength,0), maxRichTextLength);
|
|
190
|
-
return value === null || typeof value === 'string' && (!ml || value.length <= ml)
|
|
191
|
-
},
|
|
192
|
-
filter: async (value) =>{
|
|
193
|
-
return sanitizeHtml(value, optionsSanitizer);
|
|
194
|
-
},
|
|
195
|
-
anonymize: anonymizeText
|
|
196
|
-
},
|
|
197
|
-
'string_t': {
|
|
198
|
-
validate: (value, field) => {
|
|
199
|
-
if( value === null )
|
|
200
|
-
return true;
|
|
201
|
-
const ml = Math.min(Math.max(field.maxlength, 0), maxStringLength);
|
|
202
|
-
// La valeur peut être une chaîne de caractères...
|
|
203
|
-
if (typeof value === 'string') {
|
|
204
|
-
return !ml || value.length <= ml;
|
|
205
|
-
}
|
|
206
|
-
// ... ou un objet contenant une clé de type chaîne de caractères.
|
|
207
|
-
if (typeof value === 'object' &&
|
|
208
|
-
(typeof value.key === 'string' || value.key === null)) {
|
|
209
|
-
return !ml || value.key.length <= ml;
|
|
210
|
-
}
|
|
211
|
-
return false; // Invalide si ce n'est aucun des deux.
|
|
212
|
-
},
|
|
213
|
-
filter: async (value, field) => {
|
|
214
|
-
// Si la valeur est un objet avec une clé, on ne garde que la clé.
|
|
215
|
-
if (typeof value === 'object' && value !== null &&
|
|
216
|
-
(typeof value.key === 'string' || value.key === null)) {
|
|
217
|
-
return value.key || '';
|
|
218
|
-
}
|
|
219
|
-
// Sinon, on garde la valeur telle quelle (qui devrait être une chaîne).
|
|
220
|
-
return value;
|
|
221
|
-
},
|
|
222
|
-
anonymize: anonymizeText
|
|
223
|
-
},
|
|
224
|
-
password: {
|
|
225
|
-
filter: async (value) => {
|
|
226
|
-
if( value )
|
|
227
|
-
return await runCryptoWorkerTask('hash', { data: value });
|
|
228
|
-
return null;
|
|
229
|
-
},
|
|
230
|
-
validate: (value, field) => {
|
|
231
|
-
const ml = Math.min(Math.max(field.maxlength,0), maxPasswordLength);
|
|
232
|
-
return value === null || typeof value === 'string' && (!ml || value.length <= ml)
|
|
233
|
-
},
|
|
234
|
-
anonymize: anonymizeText
|
|
235
|
-
},
|
|
236
|
-
date: {
|
|
237
|
-
validate: (value, field) => {
|
|
238
|
-
if( value === null )
|
|
239
|
-
return true;
|
|
240
|
-
if( typeof(value) === 'string' && value.toLowerCase() === 'now')
|
|
241
|
-
return true;
|
|
242
|
-
if (typeof value !== 'string' ) return false;
|
|
243
|
-
const dt = new Date(value);
|
|
244
|
-
|
|
245
|
-
const dtMin = new Date(field.min || value);
|
|
246
|
-
const dtMax = new Date(field.max || value);
|
|
247
|
-
if( isNaN(dt) ){
|
|
248
|
-
return false;
|
|
249
|
-
}
|
|
250
|
-
return ( dt.getTime() >= dtMin.getTime() && dt.getTime() <= dtMax.getTime());
|
|
251
|
-
},
|
|
252
|
-
filter: async (value) => {
|
|
253
|
-
if( typeof(value) === 'string' && value.toLowerCase() === "now"){
|
|
254
|
-
return new Date().toISOString().split("T")[0];
|
|
255
|
-
}
|
|
256
|
-
if (value instanceof Date)
|
|
257
|
-
return value.toISOString().split("T")[0];
|
|
258
|
-
return value;
|
|
259
|
-
},
|
|
260
|
-
anonymize: (value, field) => {
|
|
261
|
-
const min = new Date();
|
|
262
|
-
const max = new Date();
|
|
263
|
-
min.setFullYear(min.getFullYear()-1);
|
|
264
|
-
max.setFullYear(max.getFullYear()+1);
|
|
265
|
-
return randomDate(field.min ? new Date(field.min) : min, field.max ? new Date(field.max) : max);
|
|
266
|
-
}
|
|
267
|
-
},
|
|
268
|
-
datetime: {
|
|
269
|
-
validate: (value, field) => {
|
|
270
|
-
if( typeof(value) === 'string' && value.toLowerCase() === 'now')
|
|
271
|
-
return true;
|
|
272
|
-
if (value instanceof Date || value === null)
|
|
273
|
-
return true;
|
|
274
|
-
if (typeof value !== 'string' || !value ) return false;
|
|
275
|
-
const dt = new Date(value);
|
|
276
|
-
const dtMin = new Date(field.min || value);
|
|
277
|
-
const dtMax = new Date(field.max || value);
|
|
278
|
-
if( isNaN(dt) ){
|
|
279
|
-
return false;
|
|
280
|
-
}
|
|
281
|
-
return ( dt.getTime() >= dtMin.getTime() && dt.getTime() <= dtMax.getTime());
|
|
282
|
-
},
|
|
283
|
-
filter: async (value) => {
|
|
284
|
-
if( typeof(value) === 'string' && value.toLowerCase() === "now"){
|
|
285
|
-
return new Date().toISOString();
|
|
286
|
-
}
|
|
287
|
-
if (value instanceof Date)
|
|
288
|
-
return value.toISOString();
|
|
289
|
-
return value;
|
|
290
|
-
},
|
|
291
|
-
anonymize: (value, field) => {
|
|
292
|
-
const min = new Date();
|
|
293
|
-
const max = new Date();
|
|
294
|
-
min.setFullYear(min.getFullYear()-1);
|
|
295
|
-
max.setFullYear(max.getFullYear()+1);
|
|
296
|
-
return randomDate(field.min ? new Date(field.min) : min, field.max ? new Date(field.max) : max);
|
|
297
|
-
}
|
|
298
|
-
},
|
|
299
|
-
phone: {
|
|
300
|
-
prefixRegex: /^[+]?[(]?[0-9]{2,3}[)]?$/,
|
|
301
|
-
validate: (value) => {
|
|
302
|
-
if( value === null) return true;
|
|
303
|
-
if (typeof value !== 'string') return false;
|
|
304
|
-
if( !value ) return true;
|
|
305
|
-
if( dataTypes.phone.prefixRegex.test(value) ) return true;
|
|
306
|
-
const phoneRegex = /^[+]?[(]?[0-9]{2,3}[)]?[-\s.]?[0-9]{3}[-\s.]?[0-9]{4,6}$/im;
|
|
307
|
-
return phoneRegex.test(value);
|
|
308
|
-
},
|
|
309
|
-
filter: async (value) =>{
|
|
310
|
-
if( dataTypes.phone.prefixRegex.test(value) ) return '';
|
|
311
|
-
return value;
|
|
312
|
-
},
|
|
313
|
-
anonymize: anonymizeText
|
|
314
|
-
},
|
|
315
|
-
url: {
|
|
316
|
-
validate: (value) => {
|
|
317
|
-
if( value === null) return true;
|
|
318
|
-
if (typeof value !== 'string') return false;
|
|
319
|
-
if( !value.trim() ) return true;
|
|
320
|
-
const expression = /https?:\/\/(www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&/=]*)/;
|
|
321
|
-
return expression.test(value);
|
|
322
|
-
},
|
|
323
|
-
anonymize: anonymizeText
|
|
324
|
-
},
|
|
325
|
-
number: {
|
|
326
|
-
validate: (value, field) => {
|
|
327
|
-
if( value === null) return true;
|
|
328
|
-
const min = typeof(field.min) === 'number' ? field.min : null;
|
|
329
|
-
const max = typeof(field.max) === 'number' ? field.max : null;
|
|
330
|
-
if( min !== null && max !== null && max < min )
|
|
331
|
-
return false;
|
|
332
|
-
return typeof value === 'number' && !isNaN(value) && (min === null || value >= min) && (max === null || value <= max);
|
|
333
|
-
},
|
|
334
|
-
anonymize: (value, field) => {
|
|
335
|
-
const min = typeof(field.min) === 'number' ? field.min : 0;
|
|
336
|
-
const max = typeof(field.max) === 'number' ? field.max : Math.MAX_SAFE_INTEGER;
|
|
337
|
-
return getRandom(min, max);
|
|
338
|
-
}
|
|
339
|
-
},
|
|
340
|
-
boolean: {
|
|
341
|
-
validate: (value) => value === null || typeof value === 'boolean',
|
|
342
|
-
anonymize: () => {
|
|
343
|
-
return !!getRandom(0, 1);
|
|
344
|
-
}
|
|
345
|
-
},
|
|
346
|
-
array: {
|
|
347
|
-
validate: (value, field) => {
|
|
348
|
-
if( value === null) return true;
|
|
349
|
-
if (!Array.isArray(value)) {
|
|
350
|
-
return false;
|
|
351
|
-
}
|
|
352
|
-
if (field.minItems && value.length < field.minItems) {
|
|
353
|
-
return false;
|
|
354
|
-
}
|
|
355
|
-
if (field.maxItems && value.length > field.maxItems) {
|
|
356
|
-
return false;
|
|
357
|
-
}
|
|
358
|
-
return value.every(item => {
|
|
359
|
-
if (!dataTypes[field.itemsType]) {
|
|
360
|
-
throw new Error(`Invalid itemsType: ${field.itemsType}`);
|
|
361
|
-
}
|
|
362
|
-
return dataTypes[field.itemsType].validate(item, {field, type: field.itemsType });
|
|
363
|
-
});
|
|
364
|
-
},
|
|
365
|
-
anonymize: () => []
|
|
366
|
-
},
|
|
367
|
-
enum: {
|
|
368
|
-
validate: (value) => value === null || typeof(value) === 'string',
|
|
369
|
-
anonymize: (value, field) => {
|
|
370
|
-
return field.items[Math.floor(Math.random() * field.items.length)];
|
|
371
|
-
}
|
|
372
|
-
},
|
|
373
|
-
// Types personnalisés
|
|
374
|
-
email: {
|
|
375
|
-
validate: (value) => !value || /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value),
|
|
376
|
-
anonymize: anonymizeText
|
|
377
|
-
},
|
|
378
|
-
relation: {
|
|
379
|
-
validate: (value, field) => {
|
|
380
|
-
if( field.multiple ){
|
|
381
|
-
return typeof(value) === 'object' || (Array.isArray(value) && value.length <= maxRelationsPerData && !value.some(v => {
|
|
382
|
-
return !isObjectId(v);
|
|
383
|
-
}));
|
|
384
|
-
}
|
|
385
|
-
return value === null || value === undefined || isObjectId(value) || typeof(value) === 'object';
|
|
386
|
-
},
|
|
387
|
-
anonymize: () => null
|
|
388
|
-
},
|
|
389
|
-
file: {
|
|
390
|
-
validate: (value, field) => {
|
|
391
|
-
// If no file is selected, it's considered valid (optional field)
|
|
392
|
-
if (value === null || value === undefined) {
|
|
393
|
-
return true;
|
|
394
|
-
}
|
|
395
|
-
|
|
396
|
-
// Check if the value is a string (filename or GUID)
|
|
397
|
-
if (typeof value === 'string') {
|
|
398
|
-
return true;
|
|
399
|
-
}
|
|
400
|
-
|
|
401
|
-
// Check if the value is a File object
|
|
402
|
-
if (typeof (value) === 'object') {
|
|
403
|
-
if (field.mimeTypes && !field.mimeTypes.includes(value.type)) {
|
|
404
|
-
throw new Error(i18n.t('api.validate.invalidMimeType', { type: value.type, authorized: field.mimeTypes.join(', ') }));
|
|
405
|
-
}
|
|
406
|
-
|
|
407
|
-
// Check if the file size is within the limit
|
|
408
|
-
if (value.size > (field.maxSize || maxFileSize )) {
|
|
409
|
-
return false;
|
|
410
|
-
}
|
|
411
|
-
|
|
412
|
-
return true;
|
|
413
|
-
}
|
|
414
|
-
|
|
415
|
-
return false; // Invalid type
|
|
416
|
-
},
|
|
417
|
-
filter: async (value, field, reqFile) => {
|
|
418
|
-
if (typeof value !== 'object') {
|
|
419
|
-
return null;
|
|
420
|
-
}
|
|
421
|
-
|
|
422
|
-
return value;
|
|
423
|
-
},
|
|
424
|
-
anonymize: () => null
|
|
425
|
-
},
|
|
426
|
-
color: {
|
|
427
|
-
validate: (value) => {
|
|
428
|
-
// Vérification si la valeur est une chaîne de caractères et correspond à un format de couleur hexadécimal valide.
|
|
429
|
-
return value === null ||typeof value === 'string' && /^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/.test(value);
|
|
430
|
-
},
|
|
431
|
-
filter: async (value) => {
|
|
432
|
-
// Nettoyage ou transformation de la valeur si nécessaire (par exemple, mise en majuscule des caractères hexadécimaux).
|
|
433
|
-
return value ? value.toUpperCase() : null; // Retourne null si la valeur est null ou undefined
|
|
434
|
-
},
|
|
435
|
-
anonymize: () => {
|
|
436
|
-
return randomColor({
|
|
437
|
-
format: 'hex'
|
|
438
|
-
});
|
|
439
|
-
}
|
|
440
|
-
},
|
|
441
|
-
calculated: {
|
|
442
|
-
validate: (value) => {
|
|
443
|
-
return value !== undefined && value !== null && typeof value !== 'object' && !Array.isArray(value);
|
|
444
|
-
},
|
|
445
|
-
filter: async (value) => {
|
|
446
|
-
return value;
|
|
447
|
-
}
|
|
448
|
-
},
|
|
449
|
-
richtext_t: {
|
|
450
|
-
validate: (value, field) => {
|
|
451
|
-
// La valeur doit être un objet (ou null/undefined)
|
|
452
|
-
if (value === null || typeof value === 'undefined') return true;
|
|
453
|
-
if (typeof value !== 'object' || Array.isArray(value)) return false;
|
|
454
|
-
|
|
455
|
-
// Chaque valeur dans l'objet doit être une chaîne de caractères (le HTML)
|
|
456
|
-
return Object.values(value).every(html => typeof html === 'string');
|
|
457
|
-
},
|
|
458
|
-
filter: async (value) => {
|
|
459
|
-
if (!value) return null;
|
|
460
|
-
const sanitizedObject = {};
|
|
461
|
-
for (const lang in value) {
|
|
462
|
-
if (Object.prototype.hasOwnProperty.call(value, lang)) {
|
|
463
|
-
// On réutilise le même sanitizer que pour le richtext simple
|
|
464
|
-
sanitizedObject[lang] = sanitizeHtml(value[lang], optionsSanitizer);
|
|
465
|
-
}
|
|
466
|
-
}
|
|
467
|
-
return sanitizedObject;
|
|
468
|
-
},
|
|
469
|
-
anonymize: () => ({}) // Anonymisation en objet vide
|
|
470
|
-
}
|
|
471
|
-
};
|
|
472
|
-
|
|
473
31
|
export const getAPILang = (langs) => {
|
|
474
32
|
if( typeof(langs) !== 'string')
|
|
475
33
|
return 'en';
|
|
@@ -488,4164 +46,245 @@ export const getAPILang = (langs) => {
|
|
|
488
46
|
}, []).sort(((p,r) => p.quality < r.quality ? 1 : -1))?.[0].lang.split(/[-_]/)?.[0];
|
|
489
47
|
}
|
|
490
48
|
|
|
49
|
+
export async function onInit(defaultEngine) {
|
|
50
|
+
engine = defaultEngine;
|
|
51
|
+
logger = engine.getComponent(Logger);
|
|
491
52
|
|
|
492
|
-
|
|
493
|
-
return await Event.Trigger("OnValidateModelStructure", "event", "system", modelStructure);
|
|
494
|
-
}
|
|
495
|
-
|
|
496
|
-
const validateField = (field) => {
|
|
497
|
-
|
|
498
|
-
const allowedFieldTest = (fields)=>{
|
|
499
|
-
// Check for unknown fields
|
|
500
|
-
const unknownFields = Object.keys(field).filter(f => ![...allowedFields, ...fields].includes(f));
|
|
53
|
+
engine.use(middleware({ whitelist: mongoDBWhitelist }));
|
|
501
54
|
|
|
502
|
-
|
|
503
|
-
throw new Error(i18n.t('api.validate.unknowField', `Propriété(s) non reconnue(s): '{{0}}' pour le champ '{{1}}'`, [unknownFields.join(', '), field.name]));
|
|
504
|
-
}
|
|
55
|
+
let modelsCollection, datasCollection, filesCollection, packsCollection, magnetsCollection, historyCollection;
|
|
505
56
|
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
57
|
+
if( install ) {
|
|
58
|
+
datasCollection = await createCollection("datas");
|
|
59
|
+
historyCollection = await createCollection("history");
|
|
60
|
+
filesCollection = await createCollection("files");
|
|
61
|
+
packsCollection = await createCollection("packs");
|
|
62
|
+
//data
|
|
63
|
+
const indexes = await datasCollection.indexes();
|
|
64
|
+
if (!indexes.find(i => i.name === 'genericPartialIndex')) {
|
|
65
|
+
await datasCollection.createIndex({"$**": 1}, {
|
|
66
|
+
name: 'genericPartialIndex',
|
|
67
|
+
partialFilterExpression: {
|
|
68
|
+
_model: 1,
|
|
69
|
+
_user: 1
|
|
70
|
+
}
|
|
71
|
+
});
|
|
509
72
|
}
|
|
510
|
-
}
|
|
511
|
-
|
|
512
|
-
// Check for required fields
|
|
513
|
-
if (!field.name || typeof field.name !== 'string') {
|
|
514
|
-
throw new Error(i18n.t('api.validate.requiredFieldString', "Le champ '{{0}}' est requis et doit être une chaîne de caractères.", ["name"]));
|
|
515
|
-
}
|
|
516
|
-
if (!field.type || typeof field.type !== 'string') {
|
|
517
|
-
throw new Error(i18n.t('api.validate.requiredFieldString', "Le champ '{{0}}' est requis et doit être une chaîne de caractères.", ["type"]));
|
|
518
|
-
}
|
|
519
73
|
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
case 'relation':
|
|
523
|
-
allowedFieldTest(['relation', 'multiple', 'relationFilter']);
|
|
524
|
-
if (!field.relation || typeof field.relation !== 'string' || field.relation.length > maxModelNameLength) {
|
|
525
|
-
throw new Error(i18n.t('api.validate.requiredFieldString', "Le champ '{{0}}' est requis et doit être une chaîne de caractères.", ["relation"]));
|
|
526
|
-
}
|
|
527
|
-
if (field.multiple !== undefined && typeof field.multiple !== 'boolean') {
|
|
528
|
-
throw new Error(i18n.t('api.validate.fieldBoolean', "L'attribut '{{0}}' doit être un booléen.", ["multiple"]));
|
|
529
|
-
}
|
|
530
|
-
if( field.relationFilter && typeof field.relationFilter !== 'object'){
|
|
531
|
-
throw new Error(i18n.t('api.validate.fieldObject', "L'attribut '{{0}}' doit être un objet.", ["relationFilter"]));
|
|
532
|
-
}
|
|
533
|
-
break;
|
|
534
|
-
case 'enum':
|
|
535
|
-
{
|
|
536
|
-
allowedFieldTest(['items']);
|
|
537
|
-
if (!field.items || !Array.isArray(field.items) || field.items.length === 0) {
|
|
538
|
-
throw new Error(i18n.t('api.validate.fieldStringArray', "L'attribut '{{0}}' doit être un tableau de chaines de caractères.", ["items"]));
|
|
539
|
-
}
|
|
540
|
-
let id = field.items.findIndex(item => typeof item !== 'string');
|
|
541
|
-
if( id !== -1 ){
|
|
542
|
-
throw new Error(i18n.t('api.validate.fieldString', "Le champ '{{0}}' doit être une chaîne de caractères.", ["items["+id+"]"]));
|
|
543
|
-
}
|
|
544
|
-
break;
|
|
545
|
-
}
|
|
546
|
-
case 'number':
|
|
547
|
-
allowedFieldTest(['min', 'max', 'step', 'unit', 'delay', 'gauge', 'percent']);
|
|
548
|
-
if (field.min !== undefined && typeof field.min !== 'number') {
|
|
549
|
-
throw new Error(i18n.t('api.validate.fieldNumber', "L'attribut '{{0}}' doit être un nombre.", ["min"]));
|
|
550
|
-
}
|
|
551
|
-
if (field.max !== undefined && typeof field.max !== 'number') {
|
|
552
|
-
throw new Error(i18n.t('api.validate.fieldNumber', "L'attribut '{{0}}' doit être un nombre.", ["max"]));
|
|
553
|
-
}
|
|
554
|
-
if (field.max < field.min ){
|
|
555
|
-
throw new Error(i18n.t('api.validate.inferiorTo', "L'attribut '{{0}}' doit être inférieur à l'attribut '{{1}}'.", ["min", "max"]));
|
|
556
|
-
}
|
|
557
|
-
if (field.step !== undefined && typeof field.step !== 'number') {
|
|
558
|
-
throw new Error(i18n.t('api.validate.fieldNumber', "L'attribut '{{0}}' doit être un nombre.", ["step"]));
|
|
559
|
-
}
|
|
560
|
-
if (field.unit !== undefined && typeof field.unit !== 'string') {
|
|
561
|
-
throw new Error(i18n.t('api.validate.fieldString', "Le champ '{{0}}' doit être une chaîne de caractères.", ["unit"]));
|
|
562
|
-
}
|
|
563
|
-
if (field.delay !== undefined && typeof field.delay !== 'boolean') {
|
|
564
|
-
throw new Error(i18n.t('api.validate.fieldBoolean', "Le champ '{{0}}' doit être un booléen.", ["unit"]));
|
|
565
|
-
}
|
|
566
|
-
if (field.gauge !== undefined && typeof field.gauge !== 'boolean') {
|
|
567
|
-
throw new Error(i18n.t('api.validate.fieldBoolean', "L'attribut '{{0}}' doit être un booléen.", ["gauge"]));
|
|
568
|
-
}
|
|
569
|
-
if (field.percent !== undefined && typeof field.percent !== 'boolean') {
|
|
570
|
-
throw new Error(i18n.t('api.validate.fieldBoolean', "L'attribut '{{0}}' doit être un booléen.", ["percent"]));
|
|
571
|
-
}
|
|
572
|
-
break;
|
|
573
|
-
case 'string':
|
|
574
|
-
case 'string_t':
|
|
575
|
-
case 'richtext':
|
|
576
|
-
case 'richtext_t':
|
|
577
|
-
case 'url':
|
|
578
|
-
case 'email':
|
|
579
|
-
case 'phone':
|
|
580
|
-
case 'password':
|
|
581
|
-
case 'code':
|
|
582
|
-
if (field.type === 'code')
|
|
583
|
-
allowedFieldTest(['maxlength', 'language', 'conditionBuilder', 'targetModel']);
|
|
584
|
-
else if( ['string_t', 'string'].includes(field.type))
|
|
585
|
-
allowedFieldTest(['maxlength', 'multiline']);
|
|
586
|
-
else
|
|
587
|
-
allowedFieldTest(['maxlength']);
|
|
588
|
-
if (field.maxlength !== undefined && typeof field.maxlength !== 'number') {
|
|
589
|
-
throw new Error(i18n.t('api.validate.fieldNumber', "L'attribut '{{0}}' doit être un nombre.", ["maxlength"]));
|
|
590
|
-
}
|
|
591
|
-
break;
|
|
592
|
-
case 'model':
|
|
593
|
-
case 'modelField':
|
|
594
|
-
allowedFieldTest([]);
|
|
595
|
-
break;
|
|
596
|
-
case 'object':
|
|
597
|
-
allowedFieldTest([]);
|
|
598
|
-
break;
|
|
599
|
-
case 'boolean':
|
|
600
|
-
allowedFieldTest([]);
|
|
601
|
-
break;
|
|
602
|
-
case 'date':
|
|
603
|
-
case 'datetime':
|
|
604
|
-
{
|
|
605
|
-
allowedFieldTest(['min','max']);
|
|
606
|
-
if (field.min !== undefined && typeof field.min !== 'string') {
|
|
607
|
-
throw new Error(i18n.t('api.validate.fieldString', "Le champ '{{0}}' doit être une chaîne de caractères.", ["min"]));
|
|
608
|
-
}
|
|
609
|
-
if (field.max !== undefined && typeof field.max !== 'string') {
|
|
610
|
-
throw new Error(i18n.t('api.validate.fieldString', "Le champ '{{0}}' doit être une chaîne de caractères.", ["max"]));
|
|
611
|
-
}
|
|
612
|
-
const dtMin = field.min ? new Date(field.min) : null;
|
|
613
|
-
const dtMax = field.max ? new Date(field.max) : null;
|
|
614
|
-
if( dtMin && dtMax && dtMin > dtMax){
|
|
615
|
-
throw new Error(i18n.t('api.validate.inferiorTo', "L'attribut '{{0}}' doit être inférieur à l'attribut '{{1}}'.", ["min", "max"]));
|
|
74
|
+
if (! await datasCollection.indexExists("_hash") ) {
|
|
75
|
+
await datasCollection.createIndex({_hash: 1});
|
|
616
76
|
}
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
case 'image':
|
|
620
|
-
case 'file':
|
|
621
|
-
{
|
|
622
|
-
allowedFieldTest(['mimeTypes', 'maxSize']);
|
|
623
|
-
if (field.mimeTypes !== undefined && !Array.isArray(field.mimeTypes)) {
|
|
624
|
-
throw new Error(i18n.t('api.validate.fieldStringArray', "L'attribut '{{0}}' doit être un tableau de chaines de caractères.", ["mimeTypes"]));
|
|
77
|
+
if (! await datasCollection.indexExists("_model") ) {
|
|
78
|
+
await datasCollection.createIndex({_model: 1});
|
|
625
79
|
}
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
throw new Error(i18n.t('api.validate.fieldString', "Le champ '{{0}}' doit être une chaîne de caractères.", ["mimeTypes["+id+"]"]));
|
|
80
|
+
if (! await datasCollection.indexExists("_user") ) {
|
|
81
|
+
await datasCollection.createIndex({_user: 1});
|
|
629
82
|
}
|
|
630
|
-
if (
|
|
631
|
-
|
|
83
|
+
if (!indexes.find(i => i.name === 'modelUserIndex')) {
|
|
84
|
+
await datasCollection.createIndex({_model: 1, _user: 1}, { name: 'modelUserIndex'});
|
|
632
85
|
}
|
|
633
|
-
|
|
634
|
-
|
|
86
|
+
|
|
87
|
+
const jobsCollection = await createCollection("job_locks");
|
|
88
|
+
if (! await jobsCollection.indexExists("jobTTLIndex") ) {
|
|
89
|
+
await jobsCollection.createIndex({ "lockedUntil": 1 }, { name: "jobTTLIndex", expireAfterSeconds: 0 });
|
|
635
90
|
}
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
case 'color':
|
|
639
|
-
allowedFieldTest([]);
|
|
640
|
-
return true;
|
|
641
|
-
case 'cronSchedule':
|
|
642
|
-
allowedFieldTest(['cronMask']);
|
|
643
|
-
return true;
|
|
644
|
-
case 'calculated':
|
|
645
|
-
allowedFieldTest(['calculation']);
|
|
646
|
-
return true;
|
|
647
|
-
case 'array':
|
|
648
|
-
if (!field.itemsType || typeof field.itemsType !== 'string') {
|
|
649
|
-
throw new Error(i18n.t('api.validate.fieldString', "Le champ '{{0}}' doit être une chaîne de caractères.", ["itemsType"]));
|
|
91
|
+
if (! await jobsCollection.indexExists("jobIdUnique") ) {
|
|
92
|
+
await jobsCollection.createIndex({ "jobId": 1 }, { name: "jobIdUnique", unique: true });
|
|
650
93
|
}
|
|
651
|
-
|
|
652
|
-
|
|
94
|
+
|
|
95
|
+
logger.info("Setting up indexes for 'files' collection...");
|
|
96
|
+
const filesIndexes = await filesCollection.indexes();
|
|
97
|
+
|
|
98
|
+
// Index composé pour les lookups fréquents par GUID et utilisateur
|
|
99
|
+
const compoundGuidUserIndexName = 'file_guid_user_idx';
|
|
100
|
+
if (!filesIndexes.find(i => i.name === compoundGuidUserIndexName)) {
|
|
101
|
+
await filesCollection.createIndex({ guid: 1, user: 1 }, { name: compoundGuidUserIndexName });
|
|
102
|
+
logger.info(`Created compound index '${compoundGuidUserIndexName}' on 'files' collection (guid: 1, user: 1).`);
|
|
103
|
+
} else {
|
|
104
|
+
logger.info(`Index '${compoundGuidUserIndexName}' already exists on 'files' collection.`);
|
|
653
105
|
}
|
|
654
|
-
|
|
655
|
-
|
|
106
|
+
|
|
107
|
+
const uniqueGuidIndexName = 'file_guid_unique_idx';
|
|
108
|
+
const existingGuidIndex = filesIndexes.find(i => i.name === uniqueGuidIndexName);
|
|
109
|
+
if (!existingGuidIndex) {
|
|
110
|
+
await filesCollection.createIndex({ guid: 1 }, { name: uniqueGuidIndexName, unique: true });
|
|
111
|
+
logger.info(`Created unique index '${uniqueGuidIndexName}' on 'guid' for 'files' collection.`);
|
|
112
|
+
} else if (existingGuidIndex.name !== uniqueGuidIndexName || !existingGuidIndex.unique) {
|
|
113
|
+
logger.warn(`An index on 'guid' exists for 'files' collection (name: ${existingGuidIndex.name}, unique: ${existingGuidIndex.unique}), but not matching desired spec (name: ${uniqueGuidIndexName}, unique: true). Manual review might be needed.`);
|
|
114
|
+
} else {
|
|
115
|
+
logger.info(`Unique index on 'guid' (name: '${existingGuidIndex.name}') already exists for 'files' collection.`);
|
|
656
116
|
}
|
|
657
|
-
|
|
658
|
-
|
|
117
|
+
|
|
118
|
+
if (! await packsCollection.indexExists("_user") ) {
|
|
119
|
+
await packsCollection.createIndex({_user: 1});
|
|
659
120
|
}
|
|
660
|
-
break;
|
|
661
|
-
default:
|
|
662
|
-
throw new Error(i18n.t('api.validate.unknowType',`Le type '{{0}}' n'est pas reconnu.`, [field.type]));
|
|
663
|
-
}
|
|
664
121
|
|
|
665
|
-
// Check for optional fields
|
|
666
|
-
if (field.required !== undefined && typeof field.required !== 'boolean') {
|
|
667
|
-
throw new Error(i18n.t('api.validate.fieldBoolean', "L'attribut '{{0}}' doit être un booléen.", ["required"]));
|
|
668
|
-
}
|
|
669
|
-
if (field.hint !== undefined && typeof field.hint !== 'string') {
|
|
670
|
-
throw new Error(i18n.t('api.validate.fieldString', "Le champ '{{0}}' doit être une chaîne de caractères.", ["hint"]));
|
|
671
|
-
}
|
|
672
|
-
if (field.default !== undefined && field.default !== null && typeof field.default !== typeof getDefaultForType(field) && typeof field.default !== 'function') {
|
|
673
|
-
throw new Error(i18n.t('api.validate.sameType', `L'attribut '{{0}}' doit être du même type que l'attribut '{{0}}' (${field.type}).`, ['default', 'type']));
|
|
674
|
-
}
|
|
675
|
-
if (field.validate !== undefined && typeof field.validate !== 'function') {
|
|
676
|
-
throw new Error(i18n.t('api.validate.fieldFunction', "L'attribut '{{0}}' doit être une fonction.", ['validate']));
|
|
677
|
-
}
|
|
678
|
-
if (field.unique !== undefined && typeof field.unique !== 'boolean') {
|
|
679
|
-
throw new Error(i18n.t('api.validate.fieldBoolean', "L'attribut '{{0}}' doit être un booléen.", ["unique"]));
|
|
680
|
-
}
|
|
681
|
-
if (field.placeholder !== undefined && typeof field.placeholder !== 'string') {
|
|
682
|
-
throw new Error(i18n.t('api.validate.fieldString', "Le champ '{{0}}' doit être une chaîne de caractères.", ["placeholder"]));
|
|
683
|
-
}
|
|
684
|
-
if (field.asMain !== undefined && typeof field.asMain !== 'boolean') {
|
|
685
|
-
throw new Error(i18n.t('api.validate.fieldBoolean', "L'attribut '{{0}}' doit être un booléen.", ["asMain"]));
|
|
686
|
-
}
|
|
687
|
-
if (field.unit !== undefined && typeof field.unit !== 'string') {
|
|
688
|
-
throw new Error(i18n.t('api.validate.fieldString', "Le champ '{{0}}' doit être une chaîne de caractères.", ["unit"]));
|
|
689
|
-
}
|
|
690
122
|
|
|
691
|
-
|
|
692
|
-
};
|
|
123
|
+
// Create the uploads directories
|
|
124
|
+
await mkdir(path.join("uploads", "tmp"),{ recursive: true});
|
|
693
125
|
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
126
|
+
}else {
|
|
127
|
+
modelsCollection = getCollection("models");
|
|
128
|
+
datasCollection = getCollection("datas");
|
|
129
|
+
filesCollection = getCollection("files");
|
|
130
|
+
packsCollection = getCollection("packs");
|
|
131
|
+
historyCollection = getCollection("history");
|
|
132
|
+
}
|
|
700
133
|
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
continue;
|
|
709
|
-
}
|
|
134
|
+
// Sub modules
|
|
135
|
+
backupInit(defaultEngine);
|
|
136
|
+
historyInit(defaultEngine);
|
|
137
|
+
validationInit(defaultEngine);
|
|
138
|
+
relationsInit(defaultEngine);
|
|
139
|
+
scheduleInit(defaultEngine);
|
|
140
|
+
operationsInit(defaultEngine);
|
|
710
141
|
|
|
711
|
-
switch (field.type) {
|
|
712
|
-
case 'number':
|
|
713
|
-
if (typeof value !== 'number') { // Convertir si ce n'est pas déjà un nombre
|
|
714
|
-
const num = parseFloat(value);
|
|
715
|
-
if (!isNaN(num)) {
|
|
716
|
-
convertedRecord[field.name] = num;
|
|
717
|
-
} else {
|
|
718
|
-
logger.warn(`Import: Impossible de parser le nombre pour le champ ${field.name}, valeur: ${value}. Utilisation de la valeur par défaut/null.`);
|
|
719
|
-
convertedRecord[field.name] = getDefaultForType(field);
|
|
720
|
-
}
|
|
721
|
-
}
|
|
722
|
-
break;
|
|
723
|
-
case 'boolean':
|
|
724
|
-
if (typeof value !== 'boolean') {
|
|
725
|
-
convertedRecord[field.name] = ['true', '1', 'yes', 'on'].includes(String(value).toLowerCase());
|
|
726
|
-
}
|
|
727
|
-
break;
|
|
728
|
-
case 'date':
|
|
729
|
-
case 'datetime':
|
|
730
|
-
if (String(value).toLowerCase() === 'now') {
|
|
731
|
-
convertedRecord[field.name] = 'now';
|
|
732
|
-
} else {
|
|
733
|
-
const parsedDate = new Date(value);
|
|
734
|
-
if (!isNaN(parsedDate.getTime())) {
|
|
735
|
-
convertedRecord[field.name] = field.type === 'date' ? parsedDate.toISOString().split("T")[0] : parsedDate.toISOString();
|
|
736
|
-
} else if (value) { // Ne pas logger si la valeur était initialement vide/null
|
|
737
|
-
logger.warn(`Import: Impossible de parser la date pour le champ ${field.name}, valeur: ${value}. La validation ulture s'en chargera.`);
|
|
738
|
-
}
|
|
739
|
-
}
|
|
740
|
-
break;
|
|
741
|
-
case 'array':
|
|
742
|
-
if (['csv','excel'].includes(sourceType) && typeof value === 'string') {
|
|
743
|
-
const arrayValues = value.split(/[,;]/).map(item => item.trim()).filter(item => item !== '');
|
|
744
|
-
if (field.itemsType === 'number') {
|
|
745
|
-
convertedRecord[field.name] = arrayValues.map(v => parseFloat(v)).filter(v => !isNaN(v));
|
|
746
|
-
} else {
|
|
747
|
-
convertedRecord[field.name] = arrayValues;
|
|
748
|
-
}
|
|
749
|
-
} else if (sourceType === 'json' && typeof value === 'string') {
|
|
750
|
-
try {
|
|
751
|
-
const parsedArray = JSON.parse(value);
|
|
752
|
-
if (Array.isArray(parsedArray)) {
|
|
753
|
-
convertedRecord[field.name] = parsedArray;
|
|
754
|
-
// TODO: Potentiellement convertir les éléments de parsedArray ici si nécessaire
|
|
755
|
-
} else {
|
|
756
|
-
logger.warn(`Import: La chaîne JSON pour le champ tableau ${field.name} n'a pas été parsée en tableau. Valeur: ${value}.`);
|
|
757
|
-
}
|
|
758
|
-
} catch (e) {
|
|
759
|
-
logger.warn(`Import: Impossible de parser la chaîne JSON pour le champ tableau ${field.name}. Valeur: ${value}.`);
|
|
760
|
-
}
|
|
761
|
-
}
|
|
762
|
-
// Si c'est déjà un tableau (cas JSON typique), on suppose que les types des éléments sont corrects
|
|
763
|
-
// ou seront validés par pushDataUnsecure.
|
|
764
|
-
else if (!Array.isArray(convertedRecord[field.name])) {
|
|
765
|
-
convertedRecord[field.name] = getDefaultForType(field);
|
|
766
|
-
}
|
|
767
|
-
break;
|
|
768
|
-
case 'object':
|
|
769
|
-
if (['csv','excel'].includes(sourceType)) {
|
|
770
|
-
try {
|
|
771
|
-
convertedRecord[field.name] = JSON.parse(value);
|
|
772
|
-
} catch (e) {
|
|
773
|
-
logger.warn(`Import: Impossible de parser la chaîne JSON pour le champ objet ${field.name}. Valeur: ${value}.`);
|
|
774
|
-
}
|
|
775
|
-
}
|
|
776
|
-
break;
|
|
777
|
-
case 'code':
|
|
778
|
-
if (['csv','excel'].includes(sourceType) && typeof value === 'string') {
|
|
779
|
-
try {
|
|
780
|
-
convertedRecord[field.name] = JSON.parse(value);
|
|
781
|
-
} catch (e) {
|
|
782
|
-
logger.warn(`Import: Impossible de parser la chaîne JSON pour le champ code (json) ${field.name}. Valeur: ${value}.`);
|
|
783
|
-
}
|
|
784
|
-
}
|
|
785
|
-
break;
|
|
786
|
-
}
|
|
787
|
-
}
|
|
788
|
-
}
|
|
789
|
-
return convertedRecord;
|
|
790
|
-
});
|
|
791
|
-
}
|
|
792
142
|
|
|
793
|
-
|
|
143
|
+
await registerRoutes(engine);
|
|
144
|
+
logger = engine.getComponent(Logger);
|
|
145
|
+
|
|
146
|
+
// set backup scheduler
|
|
147
|
+
schedule.scheduleJob("0 2 * * *", jobDumpUserData);
|
|
148
|
+
//await jobDumpUserData();
|
|
794
149
|
|
|
795
|
-
const datasCollection = getCollection('datas'); // Alerts are in the global collection
|
|
796
150
|
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
schedule.scheduledJobs[jobId]?.cancel();
|
|
151
|
+
schedule.scheduleJob("0 0 * * *", async () => {
|
|
152
|
+
const dt = new Date();
|
|
153
|
+
dt.setTime(dt.getTime()-1000*3600*24*14);
|
|
154
|
+
await deleteData("request", {"$lt": ["$timestamp",dt.toISOString()]}, null, false);
|
|
802
155
|
});
|
|
803
|
-
|
|
156
|
+
await scheduleAlerts();
|
|
804
157
|
|
|
805
|
-
|
|
806
|
-
* Executes a stateful alert job. It checks if a notification for an alert
|
|
807
|
-
* has already been sent and only sends one if the condition is met and no
|
|
808
|
-
* recent notification exists. The state is tracked via the 'lastNotifiedAt'
|
|
809
|
-
* field in the alert document.
|
|
810
|
-
* @param {string|ObjectId} alertId - The ID of the alert to process.
|
|
811
|
-
*/
|
|
812
|
-
async function runStatefulAlertJob(alertId) {
|
|
813
|
-
const jobId = `alert_${alertId}`;
|
|
814
|
-
logger.info(`[Scheduled Job] Cron triggered for stateful alert job ${jobId}.`);
|
|
158
|
+
// Triggers
|
|
815
159
|
|
|
816
|
-
|
|
817
|
-
const datasCollection = getCollection('datas'); // Alerts are in the global collection
|
|
160
|
+
Event.Listen("OnValidateModelStructure", async (modelStructure) =>{
|
|
818
161
|
|
|
819
|
-
|
|
820
|
-
const alertDoc = await datasCollection.findOne({ _id: new ObjectId(alertId) });
|
|
162
|
+
const objectKeys = Object.keys(modelStructure);
|
|
821
163
|
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
logger.warn(`[Scheduled Job] Alert ${alertId} not found. Cancelling job.`);
|
|
825
|
-
schedule.scheduledJobs[jobId]?.cancel();
|
|
826
|
-
return;
|
|
164
|
+
if( objectKeys.find(o => !["name", "_user", "icon", "history", "locked", "_id", "description", "maxRequestData", "fields"].includes(o)) ){
|
|
165
|
+
throw new Error(i18n.t('api.model.invalidStructure'));
|
|
827
166
|
}
|
|
828
167
|
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
return;
|
|
168
|
+
// Vérification du type de name
|
|
169
|
+
if (typeof modelStructure.name !== 'string' || !modelStructure.name) {
|
|
170
|
+
throw new Error(i18n.t("api.validate.requiredFieldString", ["name"]));
|
|
833
171
|
}
|
|
834
172
|
|
|
835
|
-
//
|
|
836
|
-
if (
|
|
837
|
-
|
|
838
|
-
return;
|
|
173
|
+
// Vérification du type de description
|
|
174
|
+
if (typeof modelStructure.description !== 'string') {
|
|
175
|
+
throw new Error(i18n.t("api.validate.fieldString", ["description"]));
|
|
839
176
|
}
|
|
840
177
|
|
|
841
|
-
//
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
filter: apiFilter,
|
|
846
|
-
limit: 1
|
|
847
|
-
}, { username: alertDoc._user });
|
|
178
|
+
// Vérification de la présence et du type du tableau fields
|
|
179
|
+
if (!Array.isArray(modelStructure.fields)) {
|
|
180
|
+
throw new Error(i18n.t('api.validate.fieldArray', ["fields"]));
|
|
181
|
+
}
|
|
848
182
|
|
|
849
|
-
//
|
|
850
|
-
|
|
851
|
-
|
|
183
|
+
// Vérification de chaque champ dans le tableau fields
|
|
184
|
+
for (const field of modelStructure.fields) {
|
|
185
|
+
validateField(field);
|
|
186
|
+
}
|
|
852
187
|
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
emailContent = await substituteVariables(msg, { count, alert: alertDoc });
|
|
867
|
-
} else {
|
|
868
|
-
// Sinon, utiliser le message par défaut
|
|
869
|
-
emailContent = i18n.t('alert.email.content', `L'alerte '${alertDoc.name}' s'est déclenchée. ${count} élément(s) correspondent à votre condition.`, { name: alertDoc.name, count: count });
|
|
188
|
+
if (modelStructure.constraints) {
|
|
189
|
+
if (!Array.isArray(modelStructure.constraints)) {
|
|
190
|
+
throw new Error('Model "constraints" property must be an array.');
|
|
191
|
+
}
|
|
192
|
+
const fieldNames = new Set(modelStructure.fields.map(f => f.name));
|
|
193
|
+
for (const constraint of modelStructure.constraints) {
|
|
194
|
+
if (constraint.type === 'unique') {
|
|
195
|
+
if (!constraint.name || !Array.isArray(constraint.keys) || constraint.keys.length === 0) {
|
|
196
|
+
throw new Error('Unique constraint must have a "name" and a non-empty "keys" array.');
|
|
197
|
+
}
|
|
198
|
+
for (const key of constraint.keys) {
|
|
199
|
+
if (!fieldNames.has(key)) {
|
|
200
|
+
throw new Error(`Constraint key "${key}" in constraint "${constraint.name}" does not exist as a field in the model.`);
|
|
870
201
|
}
|
|
871
|
-
|
|
872
|
-
await sendEmail(
|
|
873
|
-
user.email,
|
|
874
|
-
{
|
|
875
|
-
title: i18n.t('alert.email.title', `Alerte: ${alertDoc.name}`),
|
|
876
|
-
content: emailContent
|
|
877
|
-
},
|
|
878
|
-
smtpConfig,
|
|
879
|
-
userLang
|
|
880
|
-
);
|
|
881
|
-
emailSent = true;
|
|
882
|
-
logger.info(`[Scheduled Job] Email notification sent for alert ${alertId} to ${user.email}.`);
|
|
883
|
-
} else if (alertDoc.sendEmail) {
|
|
884
|
-
logger.warn(`[Scheduled Job] Could not send email for alert ${alertId}. SMTP config is missing or incomplete for user ${user.username}.`);
|
|
885
202
|
}
|
|
886
|
-
} else {
|
|
887
|
-
logger.warn(`[Scheduled Job] Could not send email for alert ${alertId}. User ${alertDoc._user} not found or has no email address.`);
|
|
888
203
|
}
|
|
889
|
-
} catch (emailError) {
|
|
890
|
-
logger.error(`[Scheduled Job] Failed to send email for alert ${alertId}:`, emailError);
|
|
891
204
|
}
|
|
892
|
-
|
|
893
|
-
// Send notification
|
|
894
|
-
const alertPayload = {
|
|
895
|
-
type: 'cron_alert',
|
|
896
|
-
triggerId: alertDoc._id.toString(),
|
|
897
|
-
triggerName: alertDoc.name,
|
|
898
|
-
timestamp: new Date().toISOString(),
|
|
899
|
-
message: `Alerte '${alertDoc.name}': ${count} élément(s) correspondent à votre condition.`,
|
|
900
|
-
emailSent
|
|
901
|
-
};
|
|
902
|
-
sendSseToUser(alertDoc._user, alertPayload);
|
|
903
|
-
|
|
904
|
-
// Update state in DB to prevent re-notification
|
|
905
|
-
await datasCollection.updateOne(
|
|
906
|
-
{ _id: new ObjectId(alertId) },
|
|
907
|
-
{ $set: { lastNotifiedAt: new Date() } }
|
|
908
|
-
);
|
|
909
|
-
} else {
|
|
910
|
-
logger.debug(`[Scheduled Job] Condition not met for alert ${alertId}. No notification sent.`);
|
|
911
205
|
}
|
|
912
206
|
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
}
|
|
916
|
-
}
|
|
207
|
+
return true; // La structure du modèle est valide
|
|
208
|
+
}, "event", "system");
|
|
917
209
|
|
|
210
|
+
}
|
|
918
211
|
|
|
919
212
|
|
|
920
|
-
|
|
921
|
-
|
|
213
|
+
/**
|
|
214
|
+
* Vérifie si l'ajout de nouvelles données dépasserait la capacité de stockage globale du serveur.
|
|
215
|
+
* @param {number} incomingDataSize - La taille des données entrantes en octets.
|
|
216
|
+
* @returns {Promise<{isSufficient: boolean, free?: number, total?: number, error?: string}>}
|
|
217
|
+
*/
|
|
218
|
+
export async function checkServerCapacity(incomingDataSize = 0) {
|
|
922
219
|
try {
|
|
923
|
-
const
|
|
924
|
-
|
|
925
|
-
// --- NOUVELLE LOGIQUE AVEC AGRÉGATION ---
|
|
926
|
-
const aggregationPipeline = [
|
|
927
|
-
// 1. Match: Ne sélectionner que les alertes actives avec une fréquence définie.
|
|
928
|
-
{
|
|
929
|
-
$match: {
|
|
930
|
-
_model: 'alert',
|
|
931
|
-
isActive: true,
|
|
932
|
-
frequency: { $exists: true, $ne: "" }
|
|
933
|
-
}
|
|
934
|
-
},
|
|
935
|
-
// 2. Sort: Trier les alertes par utilisateur, puis par date de création (les plus anciennes en premier).
|
|
936
|
-
// L'ObjectId contient un timestamp, donc trier par _id est équivalent à trier par date de création.
|
|
937
|
-
{
|
|
938
|
-
$sort: {
|
|
939
|
-
_user: 1, // Grouper par utilisateur
|
|
940
|
-
_id: 1 // Trier par date de création (ascendant)
|
|
941
|
-
}
|
|
942
|
-
},
|
|
943
|
-
// 3. Group: Regrouper toutes les alertes par utilisateur dans un tableau.
|
|
944
|
-
{
|
|
945
|
-
$group: {
|
|
946
|
-
_id: "$_user", // La clé de groupement est le nom de l'utilisateur
|
|
947
|
-
alerts: { $push: "$$ROOT" } // $$ROOT pousse le document entier dans le tableau 'alerts'
|
|
948
|
-
}
|
|
949
|
-
},
|
|
950
|
-
// 4. Project (Slice): Pour chaque utilisateur, ne garder que les X premières alertes du tableau trié.
|
|
951
|
-
{
|
|
952
|
-
$project: {
|
|
953
|
-
oldestAlerts: { $slice: ["$alerts", maxAlertsPerUser] }
|
|
954
|
-
}
|
|
955
|
-
},
|
|
956
|
-
// 5. Unwind: Déconstruire le tableau 'oldestAlerts' pour obtenir un flux de documents, un par alerte.
|
|
957
|
-
{
|
|
958
|
-
$unwind: "$oldestAlerts"
|
|
959
|
-
},
|
|
960
|
-
// 6. ReplaceRoot: Remplacer la structure du document par le contenu de l'alerte elle-même.
|
|
961
|
-
{
|
|
962
|
-
$replaceRoot: { newRoot: "$oldestAlerts" }
|
|
963
|
-
}
|
|
964
|
-
];
|
|
965
|
-
|
|
966
|
-
const alertsToSchedule = await datasCollection.aggregate(aggregationPipeline).toArray();
|
|
967
|
-
// --- FIN DE LA NOUVELLE LOGIQUE ---
|
|
220
|
+
const diskSpace = await checkDiskSpace(DATA_STORAGE_PATH);
|
|
221
|
+
const { free, size } = diskSpace;
|
|
968
222
|
|
|
969
|
-
|
|
223
|
+
// Limite maximale d'utilisation du disque (ex: 90% de la taille totale)
|
|
224
|
+
const maxAllowedUsage = size * storageSafetyMargin;
|
|
225
|
+
const currentUsage = size - free;
|
|
226
|
+
const projectedUsage = currentUsage + incomingDataSize;
|
|
970
227
|
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
// Planifier la tâche avec la nouvelle logique stateful
|
|
979
|
-
schedule.scheduleJob(jobId, alertDoc.frequency, () => runStatefulAlertJob(alertDoc._id));
|
|
980
|
-
} catch (scheduleError) {
|
|
981
|
-
logger.error(`[scheduleAlerts] Failed to schedule job ${jobId} for alert ${alertDoc._id}. Error: ${scheduleError.message}`);
|
|
982
|
-
}
|
|
228
|
+
if (projectedUsage > maxAllowedUsage) {
|
|
229
|
+
logger.warn(`[checkServerCapacity] Alert: Projected usage (${projectedUsage} bytes) would exceed the server's safety limit (${maxAllowedUsage} bytes).`);
|
|
230
|
+
return {
|
|
231
|
+
isSufficient: false,
|
|
232
|
+
free,
|
|
233
|
+
total: size
|
|
234
|
+
};
|
|
983
235
|
}
|
|
984
|
-
|
|
985
|
-
} catch (
|
|
986
|
-
logger.error(
|
|
987
|
-
|
|
988
|
-
}
|
|
989
|
-
|
|
990
|
-
/**
|
|
991
|
-
* Applique un masque et des valeurs par défaut à une expression cron.
|
|
992
|
-
* @param {string} cronString - L'expression cron d'entrbooléens. `false` pour désactiver et appliquer la valeur par défaut.
|
|
993
|
-
* @param {string[]} defaults - Un tableau de 5 chaînes de caractères pour les valeurs par défaut.
|
|
994
|
-
* @returns {string} - L'expression cron modifiée.
|
|
995
|
-
*/
|
|
996
|
-
function applyCronMask(cronString, mask, defaults) {
|
|
997
|
-
if (typeof cronString !== 'string' || cronString.trim() === '' || !mask || !defaults) {
|
|
998
|
-
return cronString;
|
|
999
|
-
}
|
|
1000
|
-
const parts = cronString.split(' ');
|
|
1001
|
-
if (parts.length < 5) {
|
|
1002
|
-
return cronString; // Laisse la validation standard gérer les formats incorrects
|
|
236
|
+
return { isSufficient: true, free, total: size };
|
|
237
|
+
} catch (err) {
|
|
238
|
+
logger.error(`[checkServerCapacity] CRITICAL: Failed to check disk space: ${err.message}. Allowing write operation as a failsafe. Please investigate disk permissions or configuration.`);
|
|
239
|
+
// Failsafe: On autorise l'ure si la vérification échoue, mais on logue une erreur critique.
|
|
240
|
+
return { isSufficient: true, error: 'Could not verify disk space.' };
|
|
1003
241
|
}
|
|
1004
|
-
|
|
1005
|
-
const newParts = parts.slice(0, 5).map((part, index) => {
|
|
1006
|
-
// Si le masque à cet index est `false`, on force la valeur par défaut.
|
|
1007
|
-
if (mask[index] === false) {
|
|
1008
|
-
return defaults[index];
|
|
1009
|
-
}
|
|
1010
|
-
// Sinon, on garde la valeur de l'utilisateur.
|
|
1011
|
-
return part;
|
|
1012
|
-
});
|
|
1013
|
-
|
|
1014
|
-
return newParts.join(' ');
|
|
1015
242
|
}
|
|
1016
243
|
|
|
1017
244
|
|
|
1018
|
-
export const
|
|
1019
|
-
|
|
1020
|
-
if(
|
|
1021
|
-
return ({success: false, error: i18n.t('api.permission.editModel', 'Cannot edit models from the API')})
|
|
1022
|
-
}
|
|
1023
|
-
|
|
1024
|
-
const dataModel = data;
|
|
1025
|
-
try {
|
|
1026
|
-
const collection = await getCollectionForUser(user);
|
|
1027
|
-
await validateModelStructure(dataModel);
|
|
1028
|
-
|
|
1029
|
-
const el = await modelsCollection.findOne({ $and: [
|
|
1030
|
-
{_user: {$exists: true}},
|
|
1031
|
-
{ _id: new ObjectId(id) },
|
|
1032
|
-
{$and: [{_user: {$exists: true}}, {$or: [{_user: user._user}, {_user: user.username}]}]
|
|
1033
|
-
}
|
|
1034
|
-
]});
|
|
1035
|
-
|
|
1036
|
-
if( !el ){
|
|
1037
|
-
return ({success: false, statusCode: 404, error: i18n.t("api.model.notFound", { model: dataModel.name })});
|
|
1038
|
-
}
|
|
1039
|
-
|
|
1040
|
-
// renommage du modèle
|
|
1041
|
-
if (typeof (data.name)==='string'&&el.name !== data.name && data.name ){
|
|
1042
|
-
await collection.updateMany({ _model: el.name }, { $set: { _model: data.name }});
|
|
1043
|
-
await modelsCollection.updateMany({ 'fields' : {
|
|
1044
|
-
'$elemMatch' : { relation: el.name }
|
|
1045
|
-
}}, {
|
|
1046
|
-
$set : {
|
|
1047
|
-
'fields.$.relation' : data.name
|
|
1048
|
-
}
|
|
1049
|
-
})
|
|
1050
|
-
}
|
|
1051
|
-
|
|
1052
|
-
const coll = await getCollectionForUser(user);
|
|
1053
|
-
// Update indexes
|
|
1054
|
-
// Update indexes
|
|
1055
|
-
if (await engine.userProvider.hasFeature(user, 'indexes')) {
|
|
1056
|
-
let indexes = [];
|
|
1057
|
-
try {
|
|
1058
|
-
// On essaie de récupérer les index existants
|
|
1059
|
-
indexes = await coll.indexes();
|
|
1060
|
-
} catch (e) {
|
|
1061
|
-
// Si la collection n'existe pas, c'est normal.
|
|
1062
|
-
// createIndex la créera. Il n'y a juste pas d'index à supprimer.
|
|
1063
|
-
if (e.codeName !== 'NamespaceNotFound') {
|
|
1064
|
-
throw e; // On relance les autres erreurs
|
|
1065
|
-
}
|
|
1066
|
-
}
|
|
1067
|
-
|
|
1068
|
-
// Le reste de votre logique de gestion d'index peut maintenant s'exécuter en toute sécurité
|
|
1069
|
-
for (const field of data.fields) {
|
|
1070
|
-
const elField = el.fields.find(f => f.name === field.name);
|
|
1071
|
-
if (!elField) continue;
|
|
1072
|
-
|
|
1073
|
-
const index = indexes.find(i => i.key[field.name] === 1 &&
|
|
1074
|
-
i.partialFilterExpression?._model === el.name &&
|
|
1075
|
-
i.partialFilterExpression?._user === user.username);
|
|
1076
|
-
|
|
1077
|
-
if (elField.index !== field.index && !field.index) {
|
|
1078
|
-
if (index) {
|
|
1079
|
-
await coll.dropIndex(index.name);
|
|
1080
|
-
}
|
|
1081
|
-
} else if (elField.index !== field.index && field.index) {
|
|
1082
|
-
if (!index) {
|
|
1083
|
-
await coll.createIndex({ [field.name]: 1 }, {
|
|
1084
|
-
partialFilterExpression: {
|
|
1085
|
-
_model: data.name,
|
|
1086
|
-
_user: user.username
|
|
1087
|
-
}
|
|
1088
|
-
});
|
|
1089
|
-
}
|
|
1090
|
-
}
|
|
1091
|
-
}
|
|
1092
|
-
}
|
|
1093
|
-
// suppression des données à la suppression des champs
|
|
1094
|
-
const unset = {};
|
|
1095
|
-
el.fields.filter(f=> !dataModel.fields.some(dt => dt.name === f.name)).map(f => f.name).forEach(f => {
|
|
1096
|
-
unset[f] = 1;
|
|
1097
|
-
});
|
|
1098
|
-
await collection.updateMany({ _model: el.name }, { $unset: unset });
|
|
1099
|
-
|
|
1100
|
-
// sauvegarde du modele
|
|
1101
|
-
const set = {...data};
|
|
1102
|
-
delete set['_id'];
|
|
1103
|
-
|
|
1104
|
-
const oid = new ObjectId(id);
|
|
1105
|
-
await modelsCollection.updateOne({_id: oid}, {$set: set});
|
|
1106
|
-
|
|
1107
|
-
modelsCache.del(user.username+'@@'+el.name);
|
|
245
|
+
export const getResource = async (guid, user) => {
|
|
246
|
+
if (!guid) throw new Error("Le GUID du fichier est requis.");
|
|
247
|
+
if (!isGUID(guid)) throw new Error("Le GUID du fichier n'est pas valide.");
|
|
1108
248
|
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
logger.error(`Erreur asynchrone lors du déclenchement des workflows pour ${model._model} ID ${model._id}:`, workflowError);
|
|
1112
|
-
});
|
|
249
|
+
const collection = getCollection("files");
|
|
250
|
+
const file = await collection.findOne({ guid });
|
|
1113
251
|
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
const plugin = await Event.Trigger("OnModelEdited", "event", "system", engine, newModel);
|
|
1117
|
-
await Event.Trigger("OnModelEdited", "event", "user", plugin?.data || newModel);
|
|
1118
|
-
return plugin || res
|
|
1119
|
-
} catch (e) {
|
|
1120
|
-
logger.error(e);
|
|
1121
|
-
return ({ success: false, error: e.message, statusCode: 500 });
|
|
252
|
+
if (!file) {
|
|
253
|
+
throw new Error("Fichier non trouvé.");
|
|
1122
254
|
}
|
|
1123
|
-
};
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
export async function middlewareEndpointAuthenticator(req, res, next) {
|
|
1127
|
-
const { path } = req.params;
|
|
1128
|
-
const method = req.method.toUpperCase();
|
|
1129
|
-
const user = await engine.userProvider.findUserByUsername(req.query._user || req.params.user || req.me.username);
|
|
1130
|
-
const datasCollection = await getCollectionForUser(user);
|
|
1131
255
|
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
method: method,
|
|
1137
|
-
isActive: true
|
|
1138
|
-
});
|
|
1139
|
-
|
|
1140
|
-
if (!endpointDef) {
|
|
1141
|
-
return res.status(404).json({ success: false, message: 'Endpoint not found.' });
|
|
1142
|
-
}
|
|
1143
|
-
|
|
1144
|
-
// Attacher la définition à la requête pour que le handler suivant puisse l'utiliser
|
|
1145
|
-
req.endpointDef = endpointDef;
|
|
1146
|
-
|
|
1147
|
-
// Si l'endpoint n'est PAS public, on exécute le vrai middleware d'authentification
|
|
1148
|
-
if (!endpointDef.isPublic) {
|
|
1149
|
-
// On "chaîne" vers le middleware authenticator standard.
|
|
1150
|
-
// Il se chargera de vérifier le token et de renvoyer une 401 si nécessaire.
|
|
1151
|
-
return middlewareAuthenticator(req, res, next);
|
|
256
|
+
// La vérification des permissions reste la même...
|
|
257
|
+
if (user.username !== 'demo' && isLocalUser(user) && !await hasPermission(["API_ADMIN", "API_READ_FILE", `API_READ_FILE_privateFile_${guid}`], user)) {
|
|
258
|
+
if (file.user !== (user._user || user.username)) {
|
|
259
|
+
throw new Error("Vous n'êtes pas autorisé à accéder à ce fichier.");
|
|
1152
260
|
}
|
|
1153
|
-
|
|
1154
|
-
// Si l'endpoint EST public, on passe simplement à la suite.
|
|
1155
|
-
next();
|
|
1156
|
-
|
|
1157
|
-
} catch (error) {
|
|
1158
|
-
logger.error(`[EndpointAuth] Critical error: ${error.message}`, error.stack);
|
|
1159
|
-
res.status(500).json({ success: false, message: 'Internal server error during endpoint authentication.' });
|
|
1160
261
|
}
|
|
1161
|
-
}
|
|
1162
|
-
|
|
1163
|
-
export async function handleCustomEndpointRequest(req, res) {
|
|
1164
|
-
const endpointDef = req.endpointDef;
|
|
1165
|
-
|
|
1166
|
-
try {
|
|
1167
|
-
let executionUser = null;
|
|
1168
262
|
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
if (!executionUser) {
|
|
1178
|
-
logger.error(`[Endpoint] Execution failed: Owner '${endpointDef._user}' for public endpoint '${endpointDef.name}' not found.`);
|
|
1179
|
-
return res.status(500).json({ success: false, message: 'Endpoint owner not found.' });
|
|
1180
|
-
}
|
|
1181
|
-
logger.info(`[Endpoint] Public endpoint '${endpointDef.name}' running as owner '${executionUser.username}'.`);
|
|
1182
|
-
} else {
|
|
1183
|
-
// Pour les endpoints privés, l'utilisateur a déjà été authentifié par le middleware.
|
|
1184
|
-
// req.me est garanti d'exister ici.
|
|
1185
|
-
executionUser = req.me;
|
|
1186
|
-
logger.info(`[Endpoint] Private endpoint '${endpointDef.name}' running as authenticated user '${executionUser.username}'.`);
|
|
1187
|
-
}
|
|
1188
|
-
|
|
1189
|
-
// 2. Préparer le contexte pour le script
|
|
1190
|
-
const contextData = {
|
|
1191
|
-
request: {
|
|
1192
|
-
// MODIFICATION: Utiliser req.body si disponible (pour les requêtes JSON comme les webhooks Stripe),
|
|
1193
|
-
// sinon, utiliser req.fields (pour les données de formulaire).
|
|
1194
|
-
body: (req.body && Object.keys(req.body).length > 0) ? req.body : (req.fields || {}),
|
|
1195
|
-
query: req.query,
|
|
1196
|
-
params: req.params,
|
|
1197
|
-
headers: req.headers
|
|
1198
|
-
}
|
|
263
|
+
// On retourne des informations différentes selon le type de stockage
|
|
264
|
+
if (file.storage === 's3') {
|
|
265
|
+
return {
|
|
266
|
+
success: true,
|
|
267
|
+
storage: 's3',
|
|
268
|
+
s3Key: file.filename, // 'filename' contient la clé S3
|
|
269
|
+
mimeType: file.mimeType
|
|
270
|
+
// Idlement, on aurait aussi le nom de fichier original ici
|
|
1199
271
|
};
|
|
1200
|
-
|
|
1201
|
-
//
|
|
1202
|
-
const
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
executionUser // Use the determined user for execution
|
|
1206
|
-
);
|
|
1207
|
-
|
|
1208
|
-
// 4. Envoyer la réponse
|
|
1209
|
-
if (result.success) {
|
|
1210
|
-
res.status(200).json(result.data);
|
|
1211
|
-
} else {
|
|
1212
|
-
logger.error(`[Endpoint] Execution failed for '${endpointDef.name}'. Error: ${result.message}`);
|
|
1213
|
-
const responseError = {
|
|
1214
|
-
success: false,
|
|
1215
|
-
message: 'Endpoint script execution failed.',
|
|
1216
|
-
details: result.message,
|
|
1217
|
-
logs: result.logs
|
|
1218
|
-
};
|
|
1219
|
-
res.status(500).json(responseError);
|
|
1220
|
-
}
|
|
1221
|
-
|
|
1222
|
-
} catch (error) {
|
|
1223
|
-
logger.error(`[Endpoint] Critical error handling request for path '${endpointDef.path}': ${error.message}`, error.stack);
|
|
1224
|
-
res.status(500).json({ success: false, message: 'An internal server error occurred.' });
|
|
1225
|
-
}
|
|
1226
|
-
}
|
|
1227
|
-
|
|
1228
|
-
export async function onInit(defaultEngine) {
|
|
1229
|
-
engine = defaultEngine;
|
|
1230
|
-
logger = engine.getComponent(Logger);
|
|
1231
|
-
|
|
1232
|
-
engine.use(middleware({ whitelist: mongoDBWhitelist }));
|
|
1233
|
-
|
|
1234
|
-
let modelsCollection, datasCollection, filesCollection, packsCollection, magnetsCollection, historyCollection;
|
|
1235
|
-
|
|
1236
|
-
if( install ) {
|
|
1237
|
-
datasCollection = await createCollection("datas");
|
|
1238
|
-
historyCollection = await createCollection("history");
|
|
1239
|
-
filesCollection = await createCollection("files");
|
|
1240
|
-
packsCollection = await createCollection("packs");
|
|
1241
|
-
//data
|
|
1242
|
-
const indexes = await datasCollection.indexes();
|
|
1243
|
-
if (!indexes.find(i => i.name === 'genericPartialIndex')) {
|
|
1244
|
-
await datasCollection.createIndex({"$**": 1}, {
|
|
1245
|
-
name: 'genericPartialIndex',
|
|
1246
|
-
partialFilterExpression: {
|
|
1247
|
-
_model: 1,
|
|
1248
|
-
_user: 1
|
|
1249
|
-
}
|
|
1250
|
-
});
|
|
1251
|
-
}
|
|
1252
|
-
|
|
1253
|
-
if (! await datasCollection.indexExists("_hash") ) {
|
|
1254
|
-
await datasCollection.createIndex({_hash: 1});
|
|
1255
|
-
}
|
|
1256
|
-
if (! await datasCollection.indexExists("_model") ) {
|
|
1257
|
-
await datasCollection.createIndex({_model: 1});
|
|
1258
|
-
}
|
|
1259
|
-
if (! await datasCollection.indexExists("_user") ) {
|
|
1260
|
-
await datasCollection.createIndex({_user: 1});
|
|
1261
|
-
}
|
|
1262
|
-
if (!indexes.find(i => i.name === 'modelUserIndex')) {
|
|
1263
|
-
await datasCollection.createIndex({_model: 1, _user: 1}, { name: 'modelUserIndex'});
|
|
1264
|
-
}
|
|
1265
|
-
|
|
1266
|
-
const jobsCollection = await createCollection("job_locks");
|
|
1267
|
-
if (! await jobsCollection.indexExists("jobTTLIndex") ) {
|
|
1268
|
-
await jobsCollection.createIndex({ "lockedUntil": 1 }, { name: "jobTTLIndex", expireAfterSeconds: 0 });
|
|
1269
|
-
}
|
|
1270
|
-
if (! await jobsCollection.indexExists("jobIdUnique") ) {
|
|
1271
|
-
await jobsCollection.createIndex({ "jobId": 1 }, { name: "jobIdUnique", unique: true });
|
|
1272
|
-
}
|
|
1273
|
-
|
|
1274
|
-
logger.info("Setting up indexes for 'files' collection...");
|
|
1275
|
-
const filesIndexes = await filesCollection.indexes();
|
|
1276
|
-
|
|
1277
|
-
// Index composé pour les lookups fréquents par GUID et utilisateur
|
|
1278
|
-
const compoundGuidUserIndexName = 'file_guid_user_idx';
|
|
1279
|
-
if (!filesIndexes.find(i => i.name === compoundGuidUserIndexName)) {
|
|
1280
|
-
await filesCollection.createIndex({ guid: 1, user: 1 }, { name: compoundGuidUserIndexName });
|
|
1281
|
-
logger.info(`Created compound index '${compoundGuidUserIndexName}' on 'files' collection (guid: 1, user: 1).`);
|
|
1282
|
-
} else {
|
|
1283
|
-
logger.info(`Index '${compoundGuidUserIndexName}' already exists on 'files' collection.`);
|
|
1284
|
-
}
|
|
1285
|
-
|
|
1286
|
-
const uniqueGuidIndexName = 'file_guid_unique_idx';
|
|
1287
|
-
const existingGuidIndex = filesIndexes.find(i => i.name === uniqueGuidIndexName);
|
|
1288
|
-
if (!existingGuidIndex) {
|
|
1289
|
-
await filesCollection.createIndex({ guid: 1 }, { name: uniqueGuidIndexName, unique: true });
|
|
1290
|
-
logger.info(`Created unique index '${uniqueGuidIndexName}' on 'guid' for 'files' collection.`);
|
|
1291
|
-
} else if (existingGuidIndex.name !== uniqueGuidIndexName || !existingGuidIndex.unique) {
|
|
1292
|
-
logger.warn(`An index on 'guid' exists for 'files' collection (name: ${existingGuidIndex.name}, unique: ${existingGuidIndex.unique}), but not matching desired spec (name: ${uniqueGuidIndexName}, unique: true). Manual review might be needed.`);
|
|
1293
|
-
} else {
|
|
1294
|
-
logger.info(`Unique index on 'guid' (name: '${existingGuidIndex.name}') already exists for 'files' collection.`);
|
|
1295
|
-
}
|
|
1296
|
-
|
|
1297
|
-
if (! await packsCollection.indexExists("_user") ) {
|
|
1298
|
-
await packsCollection.createIndex({_user: 1});
|
|
272
|
+
} else { // Par défaut, on considère le stockage local
|
|
273
|
+
// On utilise le chemin stocké en base de données
|
|
274
|
+
const filepath = file.path;
|
|
275
|
+
if (!filepath || !fs.existsSync(filepath)) {
|
|
276
|
+
throw new Error("Fichier non trouvé sur le serveur.");
|
|
1299
277
|
}
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
datasCollection = getCollection("datas");
|
|
1308
|
-
filesCollection = getCollection("files");
|
|
1309
|
-
packsCollection = getCollection("packs");
|
|
1310
|
-
historyCollection = getCollection("history");
|
|
278
|
+
return {
|
|
279
|
+
success: true,
|
|
280
|
+
storage: 'local',
|
|
281
|
+
filepath: filepath,
|
|
282
|
+
filename: file.filename,
|
|
283
|
+
mimeType: file.mimeType
|
|
284
|
+
};
|
|
1311
285
|
}
|
|
1312
|
-
|
|
1313
|
-
logger = engine.getComponent(Logger);
|
|
1314
|
-
|
|
1315
|
-
// set backup scheduler
|
|
1316
|
-
schedule.scheduleJob("0 2 * * *", jobDumpUserData);
|
|
1317
|
-
//await jobDumpUserData();
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
schedule.scheduleJob("0 0 * * *", async () => {
|
|
1321
|
-
const dt = new Date();
|
|
1322
|
-
dt.setTime(dt.getTime()-1000*3600*24*14);
|
|
1323
|
-
await deleteData("request", {"$lt": ["$timestamp",dt.toISOString()]}, null, false);
|
|
1324
|
-
});
|
|
1325
|
-
await scheduleAlerts();
|
|
1326
|
-
|
|
1327
|
-
// Triggers
|
|
1328
|
-
|
|
1329
|
-
Event.Listen("OnValidateModelStructure", async (modelStructure) =>{
|
|
1330
|
-
|
|
1331
|
-
const objectKeys = Object.keys(modelStructure);
|
|
1332
|
-
|
|
1333
|
-
if( objectKeys.find(o => !["name", "_user", "icon", "history", "locked", "_id", "description", "maxRequestData", "fields"].includes(o)) ){
|
|
1334
|
-
throw new Error(i18n.t('api.model.invalidStructure'));
|
|
1335
|
-
}
|
|
1336
|
-
|
|
1337
|
-
// Vérification du type de name
|
|
1338
|
-
if (typeof modelStructure.name !== 'string' || !modelStructure.name) {
|
|
1339
|
-
throw new Error(i18n.t("api.validate.requiredFieldString", ["name"]));
|
|
1340
|
-
}
|
|
1341
|
-
|
|
1342
|
-
// Vérification du type de description
|
|
1343
|
-
if (typeof modelStructure.description !== 'string') {
|
|
1344
|
-
throw new Error(i18n.t("api.validate.fieldString", ["description"]));
|
|
1345
|
-
}
|
|
1346
|
-
|
|
1347
|
-
// Vérification de la présence et du type du tableau fields
|
|
1348
|
-
if (!Array.isArray(modelStructure.fields)) {
|
|
1349
|
-
throw new Error(i18n.t('api.validate.fieldArray', ["fields"]));
|
|
1350
|
-
}
|
|
1351
|
-
|
|
1352
|
-
// Vérification de chaque champ dans le tableau fields
|
|
1353
|
-
for (const field of modelStructure.fields) {
|
|
1354
|
-
validateField(field);
|
|
1355
|
-
}
|
|
286
|
+
};
|
|
1356
287
|
|
|
1357
|
-
if (modelStructure.constraints) {
|
|
1358
|
-
if (!Array.isArray(modelStructure.constraints)) {
|
|
1359
|
-
throw new Error('Model "constraints" property must be an array.');
|
|
1360
|
-
}
|
|
1361
|
-
const fieldNames = new Set(modelStructure.fields.map(f => f.name));
|
|
1362
|
-
for (const constraint of modelStructure.constraints) {
|
|
1363
|
-
if (constraint.type === 'unique') {
|
|
1364
|
-
if (!constraint.name || !Array.isArray(constraint.keys) || constraint.keys.length === 0) {
|
|
1365
|
-
throw new Error('Unique constraint must have a "name" and a non-empty "keys" array.');
|
|
1366
|
-
}
|
|
1367
|
-
for (const key of constraint.keys) {
|
|
1368
|
-
if (!fieldNames.has(key)) {
|
|
1369
|
-
throw new Error(`Constraint key "${key}" in constraint "${constraint.name}" does not exist as a field in the model.`);
|
|
1370
|
-
}
|
|
1371
|
-
}
|
|
1372
|
-
}
|
|
1373
|
-
}
|
|
1374
|
-
}
|
|
1375
|
-
|
|
1376
|
-
return true; // La structure du modèle est valide
|
|
1377
|
-
}, "event", "system");
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
// Sub modules
|
|
1381
|
-
historyInit(defaultEngine);
|
|
1382
|
-
}
|
|
1383
|
-
|
|
1384
|
-
export const createModel = async (data) => {
|
|
1385
|
-
return await getCollection('models').insertOne(data);
|
|
1386
|
-
}
|
|
1387
|
-
|
|
1388
|
-
export const deleteModels = async (filter) => {
|
|
1389
|
-
return await getCollection('models').deleteMany(filter ? filter : {_user: { $exists: false }});
|
|
1390
|
-
}
|
|
1391
|
-
|
|
1392
|
-
export const getModel = async (modelName, user) => {
|
|
1393
|
-
const modelInCache = modelsCache.get((user?.username||'')+"@@"+modelName);
|
|
1394
|
-
if(modelInCache)
|
|
1395
|
-
return modelInCache;
|
|
1396
|
-
const model = await getCollection('models').findOne({name: modelName, $and: user ? [{_user: {$exists: true}}, {$or: [{_user: user._user}, {_user: user.username}]}] : [{_user: { $exists: false}}]});
|
|
1397
|
-
if (!model) {
|
|
1398
|
-
throw new Error(i18n.t('api.model.notFound', {model: modelName}));
|
|
1399
|
-
}
|
|
1400
|
-
modelsCache.set((user?.username||'')+"@@"+modelName, model);
|
|
1401
|
-
return model;
|
|
1402
|
-
}
|
|
1403
|
-
export const getModels = async () => {
|
|
1404
|
-
return await getCollection('models')?.find({'$or': [{_user: { $exists: false}}]}).toArray() || [];
|
|
1405
|
-
}
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
const removeValue = (obj, containsKey, removeParent=false) => {
|
|
1409
|
-
// Base case: If the object is not an object or array, return it as is.
|
|
1410
|
-
if (!isPlainObject(obj) && !Array.isArray(obj)) {
|
|
1411
|
-
return obj;
|
|
1412
|
-
}
|
|
1413
|
-
|
|
1414
|
-
for (const key in obj) {
|
|
1415
|
-
if (Object.prototype.hasOwnProperty.call(obj, key)) {
|
|
1416
|
-
if( removeParent ) {
|
|
1417
|
-
const value = obj[key]?.[containsKey];
|
|
1418
|
-
if (value !== undefined) {
|
|
1419
|
-
delete obj[key];
|
|
1420
|
-
} else {
|
|
1421
|
-
removeValue(obj[key], containsKey);
|
|
1422
|
-
}
|
|
1423
|
-
}else if (containsKey === key) {
|
|
1424
|
-
delete obj[key];
|
|
1425
|
-
}else{
|
|
1426
|
-
removeValue(obj[key], containsKey);
|
|
1427
|
-
}
|
|
1428
|
-
}
|
|
1429
|
-
}
|
|
1430
|
-
return obj;
|
|
1431
|
-
};
|
|
1432
|
-
|
|
1433
|
-
const changeValue = (obj, keyToChange, changeFunction, excludeKeys = [], depth=0, parentKey='') => {
|
|
1434
|
-
if(!depth){
|
|
1435
|
-
depthFilter= 0;
|
|
1436
|
-
}
|
|
1437
|
-
if (!isPlainObject(obj) && !Array.isArray(obj)) {
|
|
1438
|
-
return obj;
|
|
1439
|
-
}
|
|
1440
|
-
|
|
1441
|
-
const newObj = Array.isArray(obj) ? [] : {};
|
|
1442
|
-
|
|
1443
|
-
for (const key in obj) {
|
|
1444
|
-
if (Object.prototype.hasOwnProperty.call(obj, key)) {
|
|
1445
|
-
const topLevel = depthFilter === 0;
|
|
1446
|
-
if( key === keyToChange){
|
|
1447
|
-
depthFilter++;
|
|
1448
|
-
}
|
|
1449
|
-
const value = obj[key];
|
|
1450
|
-
if(value instanceof RegExp){
|
|
1451
|
-
newObj[key] = value;
|
|
1452
|
-
continue;
|
|
1453
|
-
}
|
|
1454
|
-
if (isPlainObject(value) && !excludeKeys.includes(key)) {
|
|
1455
|
-
newObj[key] = changeValue(value, keyToChange, changeFunction, excludeKeys,depth+1, key);
|
|
1456
|
-
} else if (Array.isArray(value) && !excludeKeys.includes(key)) {
|
|
1457
|
-
newObj[key] = value.map(item => {
|
|
1458
|
-
if (isPlainObject(item)) {
|
|
1459
|
-
return changeValue(item, keyToChange, changeFunction, excludeKeys,depth+1, key);
|
|
1460
|
-
}
|
|
1461
|
-
return item;
|
|
1462
|
-
});
|
|
1463
|
-
} else {
|
|
1464
|
-
newObj[key] = value;
|
|
1465
|
-
}
|
|
1466
|
-
if (key === keyToChange) {
|
|
1467
|
-
if (typeof changeFunction === 'function') {
|
|
1468
|
-
const newValue = changeFunction(parentKey, newObj[key], topLevel);
|
|
1469
|
-
if (newValue !== undefined) {
|
|
1470
|
-
if (isPlainObject(newValue)) {
|
|
1471
|
-
return newValue;
|
|
1472
|
-
} else {
|
|
1473
|
-
newObj[key] = newValue;
|
|
1474
|
-
}
|
|
1475
|
-
}else{
|
|
1476
|
-
//delete newObj[key];
|
|
1477
|
-
|
|
1478
|
-
}
|
|
1479
|
-
}
|
|
1480
|
-
}
|
|
1481
|
-
}
|
|
1482
|
-
}
|
|
1483
|
-
return newObj;
|
|
1484
|
-
};
|
|
1485
|
-
|
|
1486
|
-
/**
|
|
1487
|
-
* Vérifie si l'ajout de nouvelles données dépasserait la capacité de stockage globale du serveur.
|
|
1488
|
-
* @param {number} incomingDataSize - La taille des données entrantes en octets.
|
|
1489
|
-
* @returns {Promise<{isSufficient: boolean, free?: number, total?: number, error?: string}>}
|
|
1490
|
-
*/
|
|
1491
|
-
export async function checkServerCapacity(incomingDataSize = 0) {
|
|
1492
|
-
try {
|
|
1493
|
-
const diskSpace = await checkDiskSpace(DATA_STORAGE_PATH);
|
|
1494
|
-
const { free, size } = diskSpace;
|
|
1495
|
-
|
|
1496
|
-
// Limite maximale d'utilisation du disque (ex: 90% de la taille totale)
|
|
1497
|
-
const maxAllowedUsage = size * storageSafetyMargin;
|
|
1498
|
-
const currentUsage = size - free;
|
|
1499
|
-
const projectedUsage = currentUsage + incomingDataSize;
|
|
1500
|
-
|
|
1501
|
-
if (projectedUsage > maxAllowedUsage) {
|
|
1502
|
-
logger.warn(`[checkServerCapacity] Alert: Projected usage (${projectedUsage} bytes) would exceed the server's safety limit (${maxAllowedUsage} bytes).`);
|
|
1503
|
-
return {
|
|
1504
|
-
isSufficient: false,
|
|
1505
|
-
free,
|
|
1506
|
-
total: size
|
|
1507
|
-
};
|
|
1508
|
-
}
|
|
1509
|
-
return { isSufficient: true, free, total: size };
|
|
1510
|
-
} catch (err) {
|
|
1511
|
-
logger.error(`[checkServerCapacity] CRITICAL: Failed to check disk space: ${err.message}. Allowing write operation as a failsafe. Please investigate disk permissions or configuration.`);
|
|
1512
|
-
// Failsafe: On autorise l'ure si la vérification échoue, mais on logue une erreur critique.
|
|
1513
|
-
return { isSufficient: true, error: 'Could not verify disk space.' };
|
|
1514
|
-
}
|
|
1515
|
-
}
|
|
1516
|
-
|
|
1517
|
-
export const insertData = async (modelName, data, files, user, triggerWorkflow = true, waitForWorkflow = true) => {
|
|
1518
|
-
|
|
1519
|
-
// --- Vérification des permissions (inchangée) ---
|
|
1520
|
-
if (!(isDemoUser(user) && Config.Get("useDemoAccounts")) && isLocalUser(user) && (
|
|
1521
|
-
!await hasPermission(["API_ADMIN", "API_ADD_DATA", "API_ADD_DATA_" + modelName], user) ||
|
|
1522
|
-
await hasPermission(["API_ADD_DATA_NOT_" + modelName], user))) {
|
|
1523
|
-
// Renvoyer une structure d'erreur cohérente
|
|
1524
|
-
return { success: false, error: i18n.t('api.permission.addData'), statusCode: 403 };
|
|
1525
|
-
}
|
|
1526
|
-
|
|
1527
|
-
const collection = await getCollectionForUser(user);
|
|
1528
|
-
let insertedIds = []; // Pour stocker les IDs retourn par pushDataUnsecure
|
|
1529
|
-
|
|
1530
|
-
try {
|
|
1531
|
-
// --- Insertion via pushDataUnsecure (inchangée) ---
|
|
1532
|
-
insertedIds = await pushDataUnsecure(data, modelName, user, files);
|
|
1533
|
-
|
|
1534
|
-
// Check if pushDataUnsecure actually returned IDs
|
|
1535
|
-
if (!insertedIds || insertedIds.length === 0) {
|
|
1536
|
-
logger.warn(`[insertData] pushDataUnsecure did not return inserted IDs for model ${modelName}.`);
|
|
1537
|
-
// Retourner échec si l'insertion n'a pas retourné d'IDs
|
|
1538
|
-
return { success: false, unmodified: true, error: "Insertion failed, no IDs returned by core function.", statusCode: 500 };
|
|
1539
|
-
}
|
|
1540
|
-
|
|
1541
|
-
// Convertir les IDs en ObjectId pour la recherche
|
|
1542
|
-
const objectIds = insertedIds.map(id => new ObjectId(id));
|
|
1543
|
-
const insertedDocs = await collection.find({ _id: { $in: objectIds } }).toArray();
|
|
1544
|
-
|
|
1545
|
-
if (!insertedDocs || insertedDocs.length === 0) {
|
|
1546
|
-
logger.warn(`[insertData] Could not fetch inserted documents after pushDataUnsecure (IDs: ${insertedIds.join(', ')}).`);
|
|
1547
|
-
// Continuer même si on ne peut pas fetch, l'insertion a réussi.
|
|
1548
|
-
} else {
|
|
1549
|
-
// --- Logique de post-insertion (Workflows et Planification) ---
|
|
1550
|
-
const postInsertionPromises = insertedDocs.map(async (doc) => {
|
|
1551
|
-
// 1. Déclencher les workflows 'DataAdded' (si activé)
|
|
1552
|
-
if (triggerWorkflow) {
|
|
1553
|
-
const prom = triggerWorkflows(doc, user, 'DataAdded').then(e => {
|
|
1554
|
-
logger.debug(`[insertData] Triggered DataAdded workflow for ${doc._model} ID ${doc._id}.`);
|
|
1555
|
-
}).catch(e => {
|
|
1556
|
-
logger.error(`[insertData] Error triggering DataAdded workflow for ${doc._model} ID ${doc._id}:`, e);
|
|
1557
|
-
});
|
|
1558
|
-
if( waitForWorkflow){
|
|
1559
|
-
await prom;
|
|
1560
|
-
}
|
|
1561
|
-
}
|
|
1562
|
-
|
|
1563
|
-
if (doc._model === 'workflowTrigger' && doc.isActive === true && doc.cronExpression) {
|
|
1564
|
-
const jobId = `workflowTrigger_${doc._id}`;
|
|
1565
|
-
logger.info(`[insertData] Scheduling new active workflowTrigger ${doc._id} (${doc.name || 'No Name'}) with cron: "${doc.cronExpression}"`);
|
|
1566
|
-
|
|
1567
|
-
if (schedule.scheduledJobs[jobId]) {
|
|
1568
|
-
logger.warn(`[insertData] Job ${jobId} already exists. Cancelling before rescheduling.`);
|
|
1569
|
-
schedule.scheduledJobs[jobId].cancel();
|
|
1570
|
-
}
|
|
1571
|
-
|
|
1572
|
-
try {
|
|
1573
|
-
schedule.scheduleJob(jobId, doc.cronExpression, async () => {
|
|
1574
|
-
logger.info(`[Scheduled Job] Cron triggered for job ${jobId}. Attempting to run with lock...`);
|
|
1575
|
-
await runScheduledJobWithDbLock(
|
|
1576
|
-
jobId,
|
|
1577
|
-
async () => {
|
|
1578
|
-
// --- NOUVELLE LOGIQUE D'ALERTE ---
|
|
1579
|
-
logger.info(`[Scheduled Job] Executing alert logic for workflow ${doc.name} (ID: ${doc._id}) for user ${doc._user}`);
|
|
1580
|
-
|
|
1581
|
-
const alertPayload = {
|
|
1582
|
-
type: 'cron_alert',
|
|
1583
|
-
triggerId: doc._id.toString(),
|
|
1584
|
-
triggerName: doc.name,
|
|
1585
|
-
timestamp: new Date().toISOString(),
|
|
1586
|
-
message: `L'alerte planifiée '${doc.name || 'Sans nom'}' a été déclenchée.`
|
|
1587
|
-
};
|
|
1588
|
-
|
|
1589
|
-
// Envoyer l'alerte à l'utilisateur spécifique via SSE
|
|
1590
|
-
const sent = await sendSseToUser(doc._user, alertPayload);
|
|
1591
|
-
|
|
1592
|
-
if (sent) {
|
|
1593
|
-
logger.info(`[Scheduled Job] Successfully sent SSE alert for job ${jobId} to user ${doc._user}.`);
|
|
1594
|
-
} else {
|
|
1595
|
-
logger.warn(`[Scheduled Job] Could not send SSE alert for job ${jobId}. User ${doc._user} is not connected via SSE.`);
|
|
1596
|
-
}
|
|
1597
|
-
// --- FIN DE LA LOGIQUE D'ALERTE ---
|
|
1598
|
-
},
|
|
1599
|
-
doc.lockDurationMinutes || 5
|
|
1600
|
-
);
|
|
1601
|
-
});
|
|
1602
|
-
logger.info(`[insertData] Successfully scheduled job ${jobId}.`);
|
|
1603
|
-
} catch (scheduleError) {
|
|
1604
|
-
logger.error(`[insertData] Failed to schedule job ${jobId} for workflowTrigger ${doc._id}. Error: ${scheduleError.message}`);
|
|
1605
|
-
}
|
|
1606
|
-
}
|
|
1607
|
-
else if (doc._model === 'alert' && doc.isActive === true && doc.frequency) {
|
|
1608
|
-
const jobId = `alert_${doc._id}`;
|
|
1609
|
-
logger.info(`[insertData] Scheduling new active alert ${doc._id} (${doc.name}) with frequency: "${doc.frequency}"`);
|
|
1610
|
-
|
|
1611
|
-
if (schedule.scheduledJobs[jobId]) {
|
|
1612
|
-
logger.warn(`[insertData] Job ${jobId} already exists. Cancelling before rescheduling.`);
|
|
1613
|
-
schedule.scheduledJobs[jobId].cancel();
|
|
1614
|
-
}
|
|
1615
|
-
|
|
1616
|
-
try {
|
|
1617
|
-
// --- MODIFICATION ICI ---
|
|
1618
|
-
schedule.scheduleJob(jobId, doc.frequency, () => runStatefulAlertJob(doc._id));
|
|
1619
|
-
// --- FIN MODIFICATION ---
|
|
1620
|
-
logger.info(`[insertData] Successfully scheduled alert job ${jobId}.`);
|
|
1621
|
-
} catch (scheduleError) {
|
|
1622
|
-
logger.error(`[insertData] Failed to schedule alert job ${jobId}. Error: ${scheduleError.message}`);
|
|
1623
|
-
}
|
|
1624
|
-
}
|
|
1625
|
-
|
|
1626
|
-
});
|
|
1627
|
-
|
|
1628
|
-
// Attendre que toutes les opérations post-insertion (workflows, planification) soient tentées
|
|
1629
|
-
await Promise.allSettled(postInsertionPromises);
|
|
1630
|
-
}
|
|
1631
|
-
|
|
1632
|
-
// System specific event
|
|
1633
|
-
const eventPayload = { modelName, insertedIds, user };
|
|
1634
|
-
await Event.Trigger("OnDataAdded", "event", "system", engine, eventPayload);
|
|
1635
|
-
|
|
1636
|
-
// User specific event
|
|
1637
|
-
const userPayload = {...eventPayload};
|
|
1638
|
-
delete userPayload['user'];
|
|
1639
|
-
await Event.Trigger("OnDataAdded", "event", "user", userPayload);
|
|
1640
|
-
|
|
1641
|
-
// Return valid result
|
|
1642
|
-
return { success: true, insertedIds: insertedIds.map(id => id.toString()) }; // Convertir les IDs en string pour la réponse
|
|
1643
|
-
|
|
1644
|
-
} catch (error) { // Attrape les erreurs de permission ou de pushDataUnsecure
|
|
1645
|
-
logger.error(`[insertData] Main error during insertion process for model ${modelName}: ${error.message}`, error.stack);
|
|
1646
|
-
// Renvoyer une structure d'erreur cohérente
|
|
1647
|
-
return { success: false, unmodified: error.unmodified, error: error.message || "Insertion failed due to an unexpected error.", statusCode: error.statusCode || 500 };
|
|
1648
|
-
}
|
|
1649
|
-
};
|
|
1650
|
-
|
|
1651
|
-
/**
|
|
1652
|
-
* Fonction principale pour l'insertion de données avec gestion des relations
|
|
1653
|
-
* @param {Array<object>|object} data - Données à insérer
|
|
1654
|
-
* @param {string} modelName - Nom du modèle cible
|
|
1655
|
-
* @param {object} me - Utilisateur courant
|
|
1656
|
-
* @param {object} [files={}] - Fichiers associés (optionnel)
|
|
1657
|
-
* @returns {Promise<Array<string>>} IDs des documents insérés/trouvés
|
|
1658
|
-
*/
|
|
1659
|
-
export const pushDataUnsecure = async (data, modelName, me, files = {}) => {
|
|
1660
|
-
try {
|
|
1661
|
-
// 1. Initialisation et validation
|
|
1662
|
-
const {datas, model, collection} = await initializeAndValidate(data, modelName, me);
|
|
1663
|
-
if (datas.length === 0) {
|
|
1664
|
-
return [];
|
|
1665
|
-
}
|
|
1666
|
-
|
|
1667
|
-
// 2. Vérification des limites (en parallèle avec les contraintes)
|
|
1668
|
-
const [_, violations] = await Promise.all([
|
|
1669
|
-
checkLimits(datas, model, collection, me),
|
|
1670
|
-
checkCompositeUniqueConstraints(datas, model, collection, me)
|
|
1671
|
-
]);
|
|
1672
|
-
|
|
1673
|
-
if (violations.length > 0) {
|
|
1674
|
-
throw new Error(`Violation of unique constraints :\n${violations.join('\n')}`);
|
|
1675
|
-
}
|
|
1676
|
-
|
|
1677
|
-
// 3. Traitement des documents
|
|
1678
|
-
const {allInsertedIds, idMap} = await processDocuments(datas, model, collection, me);
|
|
1679
|
-
|
|
1680
|
-
// 4. Gestion des fichiers (optionnel)
|
|
1681
|
-
await handleFilesIfNeeded(allInsertedIds, files, model, collection);
|
|
1682
|
-
|
|
1683
|
-
return allInsertedIds;
|
|
1684
|
-
} catch (e) {
|
|
1685
|
-
throw e;
|
|
1686
|
-
}
|
|
1687
|
-
};
|
|
1688
|
-
|
|
1689
|
-
async function checkCompositeUniqueConstraints(datas, model, collection, user) {
|
|
1690
|
-
if (!model.constraints?.length) return [];
|
|
1691
|
-
|
|
1692
|
-
const uniqueConstraints = model.constraints.filter(c => c.type === 'unique' && c.keys?.length);
|
|
1693
|
-
if (!uniqueConstraints.length) return [];
|
|
1694
|
-
|
|
1695
|
-
// Préparation des vérifications
|
|
1696
|
-
const violations = [];
|
|
1697
|
-
const userId = user._user || user.username;
|
|
1698
|
-
|
|
1699
|
-
// Paralléliser par contrainte
|
|
1700
|
-
await Promise.all(uniqueConstraints.map(async (constraint) => {
|
|
1701
|
-
// Vérifier les champs de la contrainte
|
|
1702
|
-
const invalidFields = constraint.keys.filter(key =>
|
|
1703
|
-
!model.fields.some(f => f.name === key)
|
|
1704
|
-
);
|
|
1705
|
-
|
|
1706
|
-
if (invalidFields.length) {
|
|
1707
|
-
violations.push(`Fields used in constraint [${invalidFields.join(', ')}] '${constraint.name}' are inexistant.`);
|
|
1708
|
-
return;
|
|
1709
|
-
}
|
|
1710
|
-
|
|
1711
|
-
// Batch processing des documents (500 max)
|
|
1712
|
-
const batchSize = 50;
|
|
1713
|
-
for (let i = 0; i < datas.length; i += batchSize) {
|
|
1714
|
-
const batch = datas.slice(i, i + batchSize);
|
|
1715
|
-
|
|
1716
|
-
// Créer toutes les requêtes pour ce batch
|
|
1717
|
-
const queries = batch.flatMap(doc => {
|
|
1718
|
-
const compositeKey = {};
|
|
1719
|
-
let hasNull = false;
|
|
1720
|
-
|
|
1721
|
-
for (const key of constraint.keys) {
|
|
1722
|
-
if (doc[key] == null) {
|
|
1723
|
-
hasNull = true;
|
|
1724
|
-
break;
|
|
1725
|
-
}
|
|
1726
|
-
compositeKey[key] = doc[key];
|
|
1727
|
-
}
|
|
1728
|
-
|
|
1729
|
-
return hasNull ? [] : [{
|
|
1730
|
-
collection,
|
|
1731
|
-
query: {
|
|
1732
|
-
...compositeKey,
|
|
1733
|
-
_model: model.name,
|
|
1734
|
-
_user: userId
|
|
1735
|
-
}
|
|
1736
|
-
}];
|
|
1737
|
-
});
|
|
1738
|
-
|
|
1739
|
-
// Exécution en parallèle avec $or pour réduire les appels
|
|
1740
|
-
if (queries.length) {
|
|
1741
|
-
const matchingDocs = await collection.find({
|
|
1742
|
-
$or: queries.map(q => q.query)
|
|
1743
|
-
}).toArray();
|
|
1744
|
-
|
|
1745
|
-
if (matchingDocs.length) {
|
|
1746
|
-
// Créer un Set des clés existantes pour recherche rapide
|
|
1747
|
-
const existingKeys = new Set(
|
|
1748
|
-
matchingDocs.map(doc =>
|
|
1749
|
-
constraint.keys.map(k => doc[k]).join('|')
|
|
1750
|
-
)
|
|
1751
|
-
);
|
|
1752
|
-
|
|
1753
|
-
// Vérifier chaque document du batch
|
|
1754
|
-
batch.forEach(doc => {
|
|
1755
|
-
const keyValues = constraint.keys.map(k => doc[k]);
|
|
1756
|
-
if (keyValues.every(v => v != null)) {
|
|
1757
|
-
const compositeKey = keyValues.join('|');
|
|
1758
|
-
if (existingKeys.has(compositeKey)) {
|
|
1759
|
-
violations.push(
|
|
1760
|
-
`[${constraint.name}] Existing data : ${constraint.keys.map((k, i) => `${k}=${keyValues[i]}`).join(', ')}`
|
|
1761
|
-
);
|
|
1762
|
-
}
|
|
1763
|
-
}
|
|
1764
|
-
});
|
|
1765
|
-
}
|
|
1766
|
-
}
|
|
1767
|
-
}
|
|
1768
|
-
}));
|
|
1769
|
-
|
|
1770
|
-
return violations;
|
|
1771
|
-
}
|
|
1772
|
-
|
|
1773
|
-
/**
|
|
1774
|
-
* Initialise et valide les paramètres d'entrée
|
|
1775
|
-
*/
|
|
1776
|
-
async function initializeAndValidate(data, modelName, me) {
|
|
1777
|
-
const datas = normalizeInputData(data);
|
|
1778
|
-
if (datas.length === 0) return { datas: [], model: null, collection: null };
|
|
1779
|
-
|
|
1780
|
-
const model = await getModel(modelName, me);
|
|
1781
|
-
const collection = await getCollectionForUser(me);
|
|
1782
|
-
await validateModelStructure(model);
|
|
1783
|
-
|
|
1784
|
-
return { datas, model, collection };
|
|
1785
|
-
}
|
|
1786
|
-
|
|
1787
|
-
/**
|
|
1788
|
-
* Normalise les données d'entrée (tableau ou objet unique)
|
|
1789
|
-
*/
|
|
1790
|
-
function normalizeInputData(data) {
|
|
1791
|
-
if (Array.isArray(data)) return data;
|
|
1792
|
-
if (isPlainObject(data)) return [data];
|
|
1793
|
-
return [];
|
|
1794
|
-
}
|
|
1795
|
-
|
|
1796
|
-
/**
|
|
1797
|
-
* Vérifie toutes les limites (stockage, capacité, etc.)
|
|
1798
|
-
*/
|
|
1799
|
-
async function checkLimits(datas, model, collection, me) {
|
|
1800
|
-
const incomingDataSize = calculateDataSize(datas);
|
|
1801
|
-
const userStorageLimit = await engine.userProvider.getUserStorageLimit(me);
|
|
1802
|
-
|
|
1803
|
-
// Vérification des limites utilisateur
|
|
1804
|
-
const currentStorageUsage = await calculateTotalUserStorageUsage(me);
|
|
1805
|
-
if (currentStorageUsage + incomingDataSize > userStorageLimit) {
|
|
1806
|
-
throw new Error(i18n.t("api.data.storageLimitExceeded", {
|
|
1807
|
-
limit: Math.round(userStorageLimit / megabytes)
|
|
1808
|
-
}));
|
|
1809
|
-
}
|
|
1810
|
-
|
|
1811
|
-
// Vérification capacité serveur
|
|
1812
|
-
const serverCapacity = await checkServerCapacity(incomingDataSize);
|
|
1813
|
-
if (!serverCapacity.isSufficient) {
|
|
1814
|
-
throw new Error(i18n.t("api.data.serverStorageFull"));
|
|
1815
|
-
}
|
|
1816
|
-
|
|
1817
|
-
// Vérification nombre max de documents
|
|
1818
|
-
const count = await collection.countDocuments({ _user: me._user || me.username });
|
|
1819
|
-
if (count + datas.length > maxTotalDataPerUser) {
|
|
1820
|
-
throw new Error(i18n.t("api.data.tooManyData"));
|
|
1821
|
-
}
|
|
1822
|
-
|
|
1823
|
-
if (datas.length > maxPostData) {
|
|
1824
|
-
throw new Error(i18n.t('api.data.tooManyData'));
|
|
1825
|
-
}
|
|
1826
|
-
}
|
|
1827
|
-
|
|
1828
|
-
/**
|
|
1829
|
-
* Calcule la taille des données (en octets)
|
|
1830
|
-
*/
|
|
1831
|
-
function calculateDataSize(datas) {
|
|
1832
|
-
try {
|
|
1833
|
-
return BSON.calculateObjectSize(datas);
|
|
1834
|
-
} catch (e) {
|
|
1835
|
-
logger.warn("[Storage] Fallback to JSON.stringify for size estimation.");
|
|
1836
|
-
return JSON.stringify(datas).length;
|
|
1837
|
-
}
|
|
1838
|
-
}
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
async function processDocuments(datas, model, collection, me) {
|
|
1842
|
-
const idMap = new Map();
|
|
1843
|
-
const allInsertedIds = [];
|
|
1844
|
-
|
|
1845
|
-
const realData = await Event.Trigger("OnDataInsert", "event", "system", datas) || datas;
|
|
1846
|
-
for (const doc of realData) {
|
|
1847
|
-
try {
|
|
1848
|
-
const newDocId = await insertAndResolveRelations(doc, model, collection, me, idMap);
|
|
1849
|
-
if (newDocId) {
|
|
1850
|
-
allInsertedIds.push(newDocId.toString());
|
|
1851
|
-
}
|
|
1852
|
-
} catch (error) {
|
|
1853
|
-
// Modification clé ici : on ne catch plus les erreurs de validation
|
|
1854
|
-
throw error;
|
|
1855
|
-
}
|
|
1856
|
-
}
|
|
1857
|
-
|
|
1858
|
-
return { allInsertedIds, idMap };
|
|
1859
|
-
}
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
/**
|
|
1863
|
-
* Traite toutes les relations du document
|
|
1864
|
-
*/
|
|
1865
|
-
async function processRelations(docToProcess, model, collection, me, idMap) {
|
|
1866
|
-
const batchFinds = [];
|
|
1867
|
-
|
|
1868
|
-
// Phase 1: Préparation des requêtes
|
|
1869
|
-
for (const field of model.fields) {
|
|
1870
|
-
if (field.type !== 'relation') continue;
|
|
1871
|
-
|
|
1872
|
-
const value = docToProcess[field.name];
|
|
1873
|
-
if (value?.$find) {
|
|
1874
|
-
batchFinds.push({
|
|
1875
|
-
field: field.name,
|
|
1876
|
-
promise: searchData({
|
|
1877
|
-
filter: value.$find,
|
|
1878
|
-
limit: field.multiple ? 0 : 1,
|
|
1879
|
-
model: field.relation
|
|
1880
|
-
}, me),
|
|
1881
|
-
multiple: field.multiple
|
|
1882
|
-
});
|
|
1883
|
-
}
|
|
1884
|
-
}
|
|
1885
|
-
|
|
1886
|
-
// Phase 2: Exécution parallèle
|
|
1887
|
-
const findResults = await Promise.all(batchFinds.map(f => f.promise));
|
|
1888
|
-
|
|
1889
|
-
// Phase 3: Traitement des résultats
|
|
1890
|
-
findResults.forEach((result, index) => {
|
|
1891
|
-
const { field, multiple } = batchFinds[index];
|
|
1892
|
-
if (result.data?.length > 0) {
|
|
1893
|
-
// Cas où des documents sont trouvés
|
|
1894
|
-
docToProcess[field] = multiple
|
|
1895
|
-
? result.data.map(r => r._id.toString())
|
|
1896
|
-
: result.data[0]._id.toString();
|
|
1897
|
-
} else {
|
|
1898
|
-
// Cas où AUCUN document n'est trouvé : il faut nettoyer le champ !
|
|
1899
|
-
docToProcess[field] = multiple ? [] : null;
|
|
1900
|
-
}
|
|
1901
|
-
});
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
for (const field of model.fields) {
|
|
1905
|
-
if (field.type !== 'relation') continue;
|
|
1906
|
-
|
|
1907
|
-
const fieldName = field.name;
|
|
1908
|
-
const relationValue = docToProcess[fieldName];
|
|
1909
|
-
if (!relationValue || typeof relationValue !== 'object') continue;
|
|
1910
|
-
|
|
1911
|
-
const relatedModel = await getModel(field.relation, me);
|
|
1912
|
-
|
|
1913
|
-
if( !Array.isArray(relationValue) && relationValue['$find'] ) {
|
|
1914
|
-
|
|
1915
|
-
}else if (Array.isArray(relationValue)) {
|
|
1916
|
-
// Relation multiple (tableau)
|
|
1917
|
-
docToProcess[fieldName] = await processMultipleRelations(
|
|
1918
|
-
relationValue,
|
|
1919
|
-
relatedModel,
|
|
1920
|
-
collection,
|
|
1921
|
-
me,
|
|
1922
|
-
idMap
|
|
1923
|
-
);
|
|
1924
|
-
} else if (isPlainObject(relationValue)) {
|
|
1925
|
-
// Relation simple (objet)
|
|
1926
|
-
docToProcess[fieldName] = await processSingleRelation(
|
|
1927
|
-
relationValue,
|
|
1928
|
-
relatedModel,
|
|
1929
|
-
collection,
|
|
1930
|
-
me,
|
|
1931
|
-
idMap
|
|
1932
|
-
);
|
|
1933
|
-
}
|
|
1934
|
-
}
|
|
1935
|
-
}
|
|
1936
|
-
|
|
1937
|
-
/**
|
|
1938
|
-
* Traite une relation multiple (tableau)
|
|
1939
|
-
*/
|
|
1940
|
-
async function processMultipleRelations(items, relatedModel, collection, me, idMap) {
|
|
1941
|
-
const newRelationIds = await Promise.all(
|
|
1942
|
-
items.map(item => processRelationItem(item, relatedModel, collection, me, idMap))
|
|
1943
|
-
);
|
|
1944
|
-
return newRelationIds.filter(id => id).map(id => id.toString());
|
|
1945
|
-
}
|
|
1946
|
-
|
|
1947
|
-
/**
|
|
1948
|
-
* Traite une relation simple (objet)
|
|
1949
|
-
*/
|
|
1950
|
-
async function processSingleRelation(item, relatedModel, collection, me, idMap) {
|
|
1951
|
-
const newId = await processRelationItem(item, relatedModel, collection, me, idMap);
|
|
1952
|
-
return newId ? newId.toString() : null;
|
|
1953
|
-
}
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
async function processRelationItem(item, relatedModel, collection, me, idMap) {
|
|
1957
|
-
// Cas 1: ID existant (string ou ObjectId)
|
|
1958
|
-
if (isObjectId(item) || typeof item === 'string') {
|
|
1959
|
-
const originalId = typeof item === 'string' ? item : item.toString();
|
|
1960
|
-
|
|
1961
|
-
// Vérifier si cet ID a déjà été mappé (cas d'une référence circulaire)
|
|
1962
|
-
if (idMap.has(originalId)) {
|
|
1963
|
-
return idMap.get(originalId);
|
|
1964
|
-
}
|
|
1965
|
-
|
|
1966
|
-
// Sinon, vérifier si l'ID existe en base
|
|
1967
|
-
const existing = await collection.findOne({
|
|
1968
|
-
_id: new ObjectId(originalId),
|
|
1969
|
-
_model: relatedModel.name,
|
|
1970
|
-
$or: [{_user: me._user || me.username}, {_user: {$exists: false}}]
|
|
1971
|
-
});
|
|
1972
|
-
|
|
1973
|
-
if (existing) {
|
|
1974
|
-
return existing._id; // Conserver l'ID original
|
|
1975
|
-
}
|
|
1976
|
-
}
|
|
1977
|
-
|
|
1978
|
-
// Cas 2: Objet complet à importer
|
|
1979
|
-
if (isPlainObject(item)) {
|
|
1980
|
-
const relationDoc = prepareDocument(item, relatedModel, me);
|
|
1981
|
-
applyDefaultValues(relationDoc, relatedModel);
|
|
1982
|
-
|
|
1983
|
-
// Si l'objet a un _id, essayer de le conserver
|
|
1984
|
-
if (item._id) {
|
|
1985
|
-
const originalId = item._id.toString();
|
|
1986
|
-
|
|
1987
|
-
// Vérifier si l'ID existe déjà en base
|
|
1988
|
-
const existing = await collection.findOne({
|
|
1989
|
-
_id: new ObjectId(originalId),
|
|
1990
|
-
_model: relatedModel.name,
|
|
1991
|
-
$or: [{_user: me._user || me.username}, {_user: {$exists: false}}]
|
|
1992
|
-
});
|
|
1993
|
-
|
|
1994
|
-
if (existing) {
|
|
1995
|
-
return existing._id; // Utiliser l'ID existant
|
|
1996
|
-
}
|
|
1997
|
-
|
|
1998
|
-
// Si l'ID n'existe pas encore, l'utiliser pour le nouvel insert
|
|
1999
|
-
relationDoc._id = new ObjectId(originalId);
|
|
2000
|
-
}
|
|
2001
|
-
|
|
2002
|
-
const relationHash = relationDoc._hash;
|
|
2003
|
-
const cacheKey = `${relatedModel.name}:${relationHash}`;
|
|
2004
|
-
|
|
2005
|
-
// Vérification dans le cache
|
|
2006
|
-
const cachedId = relationCache.get(cacheKey);
|
|
2007
|
-
if (cachedId !== undefined) {
|
|
2008
|
-
return cachedId;
|
|
2009
|
-
}
|
|
2010
|
-
|
|
2011
|
-
// Vérification en base de données par hash
|
|
2012
|
-
const existingByHash = await collection.findOne({
|
|
2013
|
-
_hash: relationHash,
|
|
2014
|
-
_model: relatedModel.name,
|
|
2015
|
-
_user: relationDoc._user
|
|
2016
|
-
}, { projection: { _id: 1 } });
|
|
2017
|
-
|
|
2018
|
-
if (existingByHash) {
|
|
2019
|
-
relationCache.set(cacheKey, existingByHash._id);
|
|
2020
|
-
return existingByHash._id;
|
|
2021
|
-
}
|
|
2022
|
-
|
|
2023
|
-
const newId = await insertAndResolveRelations(item, relatedModel, collection, me, idMap);
|
|
2024
|
-
relationCache.set(cacheKey, newId);
|
|
2025
|
-
return newId;
|
|
2026
|
-
}
|
|
2027
|
-
|
|
2028
|
-
return null;
|
|
2029
|
-
}
|
|
2030
|
-
// Fonction pour vider le cache si besoin
|
|
2031
|
-
function clearRelationCache() {
|
|
2032
|
-
relationCache.flushAll();
|
|
2033
|
-
}
|
|
2034
|
-
|
|
2035
|
-
// Fonction pour obtenir les stats du cache (utile pour le debug)
|
|
2036
|
-
function getCacheStats() {
|
|
2037
|
-
return relationCache.getStats();
|
|
2038
|
-
}
|
|
2039
|
-
|
|
2040
|
-
/**
|
|
2041
|
-
* Applique les filtres de champ définis dans le modèle
|
|
2042
|
-
*/
|
|
2043
|
-
async function applyFieldFilters(docToProcess, model) {
|
|
2044
|
-
for (const field of model.fields) {
|
|
2045
|
-
docToProcess[field.name] = typeof(docToProcess[field.name]) === 'undefined' || docToProcess[field.name] === null ? field.default:docToProcess[field.name];
|
|
2046
|
-
if (dataTypes[field.type]?.filter) {
|
|
2047
|
-
const filter = await dataTypes[field.type].filter(
|
|
2048
|
-
docToProcess[field.name],
|
|
2049
|
-
field
|
|
2050
|
-
);
|
|
2051
|
-
const realFilter = await Event.Trigger('OnDataFilter', "event", "system",filter, field, docToProcess );
|
|
2052
|
-
docToProcess[field.name] = realFilter || filter;
|
|
2053
|
-
}
|
|
2054
|
-
}
|
|
2055
|
-
}
|
|
2056
|
-
|
|
2057
|
-
|
|
2058
|
-
/**
|
|
2059
|
-
* Valide la structure et le contenu du document selon le modèle
|
|
2060
|
-
*/
|
|
2061
|
-
async function validateModelData(doc, model, isPatch = false) {
|
|
2062
|
-
if (!isPatch) {
|
|
2063
|
-
model.fields.forEach(field => {
|
|
2064
|
-
const value = doc[field.name];
|
|
2065
|
-
if (field.required) {
|
|
2066
|
-
if (value === undefined && !('default' in field)) {
|
|
2067
|
-
throw new Error(i18n.t('api.field.missingRequired', { field: field.name + " (" + model.name + ")" }));
|
|
2068
|
-
}
|
|
2069
|
-
if (value === '' || value === null) {
|
|
2070
|
-
throw new Error(i18n.t('api.field.requiredCannotBeEmpty', { field: field.name }));
|
|
2071
|
-
}
|
|
2072
|
-
}
|
|
2073
|
-
});
|
|
2074
|
-
}
|
|
2075
|
-
|
|
2076
|
-
// 2. Validation des types de champs (toujours exécutée pour les champs fournis)
|
|
2077
|
-
for (const [fieldName, value] of Object.entries(doc)) {
|
|
2078
|
-
const fieldDef = model.fields.find(f => f.name === fieldName);
|
|
2079
|
-
if (!fieldDef) continue; // On ignore les champs supplémentaires
|
|
2080
|
-
|
|
2081
|
-
const validator = dataTypes[fieldDef.type]?.validate;
|
|
2082
|
-
const valid = validator && validator(value, fieldDef);
|
|
2083
|
-
const realValidation = await Event.Trigger('OnDataValidate', "event", "system", value, fieldDef,doc );
|
|
2084
|
-
if (!(valid || realValidation)) {
|
|
2085
|
-
throw new Error(i18n.t('api.field.validationFailed', { field: fieldName, value }));
|
|
2086
|
-
}
|
|
2087
|
-
}
|
|
2088
|
-
}
|
|
2089
|
-
|
|
2090
|
-
/**
|
|
2091
|
-
* Applique les valeurs par défaut aux champs manquants
|
|
2092
|
-
*/
|
|
2093
|
-
function applyDefaultValues(doc, model) {
|
|
2094
|
-
for (const field of model.fields) {
|
|
2095
|
-
// Si le champ n'est pas défini et a une valeur par défaut
|
|
2096
|
-
if (!(field.name in doc) && 'default' in field) {
|
|
2097
|
-
doc[field.name] = typeof field.default === 'function'
|
|
2098
|
-
? field.default()
|
|
2099
|
-
: field.default;
|
|
2100
|
-
}
|
|
2101
|
-
}
|
|
2102
|
-
}
|
|
2103
|
-
|
|
2104
|
-
async function insertAndResolveRelations(doc, model, collection, me, idMap) {
|
|
2105
|
-
const originalId = doc._id?.toString();
|
|
2106
|
-
|
|
2107
|
-
// Si cet ID a déjà été traité, retourner le nouvel ID mappé
|
|
2108
|
-
if (originalId && idMap.has(originalId)) {
|
|
2109
|
-
return idMap.get(originalId);
|
|
2110
|
-
}
|
|
2111
|
-
|
|
2112
|
-
const docToProcess = prepareDocument(doc, model, me);
|
|
2113
|
-
applyDefaultValues(docToProcess, model);
|
|
2114
|
-
|
|
2115
|
-
// Si le document a un _id original et qu'il n'existe pas encore, le conserver
|
|
2116
|
-
if (originalId && !await collection.findOne({ _id: new ObjectId(originalId) })) {
|
|
2117
|
-
docToProcess._id = new ObjectId(originalId);
|
|
2118
|
-
}
|
|
2119
|
-
|
|
2120
|
-
await validateModelData(docToProcess, model);
|
|
2121
|
-
await processRelations(docToProcess, model, collection, me, idMap);
|
|
2122
|
-
await validateModelData(docToProcess, model);
|
|
2123
|
-
await applyFieldFilters(docToProcess, model);
|
|
2124
|
-
await checkUniqueFields(docToProcess, model, collection);
|
|
2125
|
-
|
|
2126
|
-
const existingDoc = await findExistingDocument(docToProcess, collection);
|
|
2127
|
-
if (existingDoc) {
|
|
2128
|
-
cacheDocumentId(originalId, existingDoc._id, idMap);
|
|
2129
|
-
return existingDoc._id;
|
|
2130
|
-
}
|
|
2131
|
-
|
|
2132
|
-
for (const field of model.fields) {
|
|
2133
|
-
if (field.type === 'relation' && field.relationFilter && docToProcess[field.name]) {
|
|
2134
|
-
|
|
2135
|
-
const relatedIds = Array.isArray(docToProcess[field.name])
|
|
2136
|
-
? docToProcess[field.name]
|
|
2137
|
-
: [docToProcess[field.name]];
|
|
2138
|
-
|
|
2139
|
-
// Préparer un filtre global : match si _id dans relatedIds ET respecte relationFilter
|
|
2140
|
-
const validationQuery = {
|
|
2141
|
-
$and: [
|
|
2142
|
-
{ $in: ['$_id', relatedIds.map(id => ({ $toObjectId: id }))] },
|
|
2143
|
-
field.relationFilter
|
|
2144
|
-
]
|
|
2145
|
-
};
|
|
2146
|
-
|
|
2147
|
-
const relatedDocs = await searchData({
|
|
2148
|
-
filter: validationQuery,
|
|
2149
|
-
model: field.relation,
|
|
2150
|
-
limit: relatedIds.length
|
|
2151
|
-
}, me);
|
|
2152
|
-
|
|
2153
|
-
if ((relatedDocs?.count || 0) !== relatedIds.length) {
|
|
2154
|
-
const invalidIds = relatedIds.filter(id =>
|
|
2155
|
-
!relatedDocs.data.some(doc => doc._id.toString() === id.toString())
|
|
2156
|
-
);
|
|
2157
|
-
throw new Error(
|
|
2158
|
-
i18n.t(
|
|
2159
|
-
'api.data.relationFilterFailed',
|
|
2160
|
-
'Les valeurs {{values}} pour le champ {{field}} ne respectent pas le filtre de relation défini.',
|
|
2161
|
-
{ field: field.name, values: invalidIds.join(', ') }
|
|
2162
|
-
)
|
|
2163
|
-
);
|
|
2164
|
-
}
|
|
2165
|
-
}
|
|
2166
|
-
}
|
|
2167
|
-
|
|
2168
|
-
|
|
2169
|
-
// Insertion en conservant éventuellement l'ID original
|
|
2170
|
-
const result = docToProcess._id
|
|
2171
|
-
? await collection.insertOne(docToProcess)
|
|
2172
|
-
: await collection.insertOne(docToProcess);
|
|
2173
|
-
|
|
2174
|
-
const insertedId = result.insertedId;
|
|
2175
|
-
cacheDocumentId(originalId, insertedId, idMap);
|
|
2176
|
-
|
|
2177
|
-
return insertedId;
|
|
2178
|
-
}
|
|
2179
|
-
|
|
2180
|
-
// Nouvelle fonction pour vérifier les champs uniques
|
|
2181
|
-
async function checkUniqueFields(doc, model, collection) {
|
|
2182
|
-
const uniqueFields = model.fields.filter(f => f.unique);
|
|
2183
|
-
|
|
2184
|
-
for (const field of uniqueFields) {
|
|
2185
|
-
const value = doc[field.name];
|
|
2186
|
-
if (value === undefined || value === null) continue;
|
|
2187
|
-
|
|
2188
|
-
const existing = await collection.findOne({
|
|
2189
|
-
[field.name]: value,
|
|
2190
|
-
_model: model.name,
|
|
2191
|
-
_user: doc._user
|
|
2192
|
-
});
|
|
2193
|
-
|
|
2194
|
-
if (existing) {
|
|
2195
|
-
// Utilisation de i18n pour un message d'erreur standardisé
|
|
2196
|
-
throw new Error(i18n.t('api.data.duplicateValue', { field: field.name, value: value }));
|
|
2197
|
-
}
|
|
2198
|
-
}
|
|
2199
|
-
}
|
|
2200
|
-
|
|
2201
|
-
function prepareDocument(doc, model, me) {
|
|
2202
|
-
const docToProcess = { ...doc };
|
|
2203
|
-
delete docToProcess._id;
|
|
2204
|
-
|
|
2205
|
-
// AJOUT: Nettoyage des champs non définis dans le modèle
|
|
2206
|
-
for (const key of Object.keys(docToProcess)) {
|
|
2207
|
-
if (!model.fields.some(f => f.name === key) && !key.startsWith('_')) {
|
|
2208
|
-
delete docToProcess[key];
|
|
2209
|
-
}
|
|
2210
|
-
}
|
|
2211
|
-
|
|
2212
|
-
docToProcess._model = model.name;
|
|
2213
|
-
docToProcess._user = me._user || me.username;
|
|
2214
|
-
docToProcess._hash = getFieldValueHash(model, docToProcess);
|
|
2215
|
-
|
|
2216
|
-
return docToProcess;
|
|
2217
|
-
}
|
|
2218
|
-
/**
|
|
2219
|
-
* Cherche un document existant par son hash
|
|
2220
|
-
*/
|
|
2221
|
-
async function findExistingDocument(docToProcess, collection) {
|
|
2222
|
-
return await collection.findOne({
|
|
2223
|
-
_hash: docToProcess._hash,
|
|
2224
|
-
_model: docToProcess._model,
|
|
2225
|
-
_user: docToProcess._user
|
|
2226
|
-
});
|
|
2227
|
-
}
|
|
2228
|
-
|
|
2229
|
-
/**
|
|
2230
|
-
* Insère le document dans la collection
|
|
2231
|
-
*/
|
|
2232
|
-
async function insertDocument(docToProcess, collection) {
|
|
2233
|
-
const result = await collection.insertOne(docToProcess);
|
|
2234
|
-
return result.insertedId;
|
|
2235
|
-
}
|
|
2236
|
-
|
|
2237
|
-
/**
|
|
2238
|
-
* Met en cache la correspondance d'ID
|
|
2239
|
-
*/
|
|
2240
|
-
function cacheDocumentId(originalId, newId, idMap) {
|
|
2241
|
-
if (originalId && newId) {
|
|
2242
|
-
idMap.set(originalId, newId);
|
|
2243
|
-
}
|
|
2244
|
-
}
|
|
2245
|
-
|
|
2246
|
-
/**
|
|
2247
|
-
* Gestion des fichiers (à implémenter selon besoins)
|
|
2248
|
-
*/
|
|
2249
|
-
async function handleFilesIfNeeded(insertedIds, files, model, collection) {
|
|
2250
|
-
// Implémentation spécifique à votre application
|
|
2251
|
-
// Ex: association des fichiers uploadés aux documents insérés
|
|
2252
|
-
}
|
|
2253
|
-
const checkHash = async (me, model, hash, excludeId = null) => {
|
|
2254
|
-
const collection = await getCollectionForUser(me);
|
|
2255
|
-
const query = {
|
|
2256
|
-
_model: model.name,
|
|
2257
|
-
_hash: hash,
|
|
2258
|
-
...(excludeId && { _id: { $ne: new ObjectId(excludeId) } })
|
|
2259
|
-
};
|
|
2260
|
-
|
|
2261
|
-
console.log("Query being executed:", JSON.stringify(query, null, 2));
|
|
2262
|
-
|
|
2263
|
-
const count = await collection.countDocuments(query);
|
|
2264
|
-
return count > 0;
|
|
2265
|
-
};
|
|
2266
|
-
|
|
2267
|
-
|
|
2268
|
-
export const getResource = async (guid, user) => {
|
|
2269
|
-
if (!guid) throw new Error("Le GUID du fichier est requis.");
|
|
2270
|
-
if (!isGUID(guid)) throw new Error("Le GUID du fichier n'est pas valide.");
|
|
2271
|
-
|
|
2272
|
-
const collection = getCollection("files");
|
|
2273
|
-
const file = await collection.findOne({ guid });
|
|
2274
|
-
|
|
2275
|
-
if (!file) {
|
|
2276
|
-
throw new Error("Fichier non trouvé.");
|
|
2277
|
-
}
|
|
2278
|
-
|
|
2279
|
-
// La vérification des permissions reste la même...
|
|
2280
|
-
if (user.username !== 'demo' && isLocalUser(user) && !await hasPermission(["API_ADMIN", "API_READ_FILE", `API_READ_FILE_privateFile_${guid}`], user)) {
|
|
2281
|
-
if (file.user !== (user._user || user.username)) {
|
|
2282
|
-
throw new Error("Vous n'êtes pas autorisé à accéder à ce fichier.");
|
|
2283
|
-
}
|
|
2284
|
-
}
|
|
2285
|
-
|
|
2286
|
-
// On retourne des informations différentes selon le type de stockage
|
|
2287
|
-
if (file.storage === 's3') {
|
|
2288
|
-
return {
|
|
2289
|
-
success: true,
|
|
2290
|
-
storage: 's3',
|
|
2291
|
-
s3Key: file.filename, // 'filename' contient la clé S3
|
|
2292
|
-
mimeType: file.mimeType
|
|
2293
|
-
// Idlement, on aurait aussi le nom de fichier original ici
|
|
2294
|
-
};
|
|
2295
|
-
} else { // Par défaut, on considère le stockage local
|
|
2296
|
-
// On utilise le chemin stocké en base de données
|
|
2297
|
-
const filepath = file.path;
|
|
2298
|
-
if (!filepath || !fs.existsSync(filepath)) {
|
|
2299
|
-
throw new Error("Fichier non trouvé sur le serveur.");
|
|
2300
|
-
}
|
|
2301
|
-
return {
|
|
2302
|
-
success: true,
|
|
2303
|
-
storage: 'local',
|
|
2304
|
-
filepath: filepath,
|
|
2305
|
-
filename: file.filename,
|
|
2306
|
-
mimeType: file.mimeType
|
|
2307
|
-
};
|
|
2308
|
-
}
|
|
2309
|
-
};
|
|
2310
|
-
|
|
2311
|
-
export const patchData = async (modelName, filter, data, files, user, triggerWorkflow = true, waitForWorkflow = false) => {
|
|
2312
|
-
return await internalEditOrPatchData(modelName, filter, data, files, user, true, triggerWorkflow, waitForWorkflow);
|
|
2313
|
-
};
|
|
2314
|
-
|
|
2315
|
-
export const editData = async (modelName, filter, data, files, user, triggerWorkflow = true, waitForWorkflow = false) => {
|
|
2316
|
-
return await internalEditOrPatchData(modelName, filter, data, files, user, false, triggerWorkflow, waitForWorkflow);
|
|
2317
|
-
};
|
|
2318
|
-
|
|
2319
|
-
const internalEditOrPatchData = async (modelName, filter, data, files, user, isPatch, triggerWorkflow = true, waitForWorkflow = false) => {
|
|
2320
|
-
try {
|
|
2321
|
-
// 1. Vérification des permissions
|
|
2322
|
-
if (user.username !== 'demo' && isLocalUser(user) && (
|
|
2323
|
-
!await hasPermission(["API_ADMIN", "API_EDIT_DATA", "API_EDIT_DATA_" + modelName], user) ||
|
|
2324
|
-
await hasPermission(["API_EDIT_DATA_NOT_" + modelName], user))) {
|
|
2325
|
-
throw new Error(i18n.t("api.permission.editData"));
|
|
2326
|
-
}
|
|
2327
|
-
|
|
2328
|
-
const collection = await getCollectionForUser(user);
|
|
2329
|
-
const model = await modelsCollection.findOne({name: modelName, _user: user.username});
|
|
2330
|
-
if (!model) {
|
|
2331
|
-
throw new Error(i18n.t("api.model.notFound", {model: modelName}));
|
|
2332
|
-
}
|
|
2333
|
-
|
|
2334
|
-
// 2. Récupération des documents existants et de leur hash original
|
|
2335
|
-
const existingDocs = (await searchData({model: modelName, filter}, user))?.data;
|
|
2336
|
-
if (!existingDocs || existingDocs.length === 0) {
|
|
2337
|
-
return {success: false, error: i18n.t("api.data.notFound")};
|
|
2338
|
-
}
|
|
2339
|
-
const ids = existingDocs.map(d => new ObjectId(d._id));
|
|
2340
|
-
const originalHash = existingDocs[0]._hash; // Sauvegarde du hash avant modification
|
|
2341
|
-
|
|
2342
|
-
// 3. Préparation des données de mise à jour (inchangé)
|
|
2343
|
-
const updateData = {...data};
|
|
2344
|
-
delete updateData._model;
|
|
2345
|
-
delete updateData._user;
|
|
2346
|
-
|
|
2347
|
-
// Traitement des fichiers (inchangé)
|
|
2348
|
-
const fileFields = model.fields.filter(f => f.type === 'file' || (f.type === 'array' && f.itemsType === 'file'));
|
|
2349
|
-
for (const field of fileFields) {
|
|
2350
|
-
if (files?.[field.name+'[0]']) {
|
|
2351
|
-
if (field.type === 'file') {
|
|
2352
|
-
updateData[field.name] = await addFile(files[field.name+'[0]'][0], user);
|
|
2353
|
-
} else if (field.type === 'array' && field.itemsType === 'file') {
|
|
2354
|
-
const currentFiles = existingDocs[0]?.[field.name] || [];
|
|
2355
|
-
const newFiles = await processFileArray(files[field.name+'[0]'], currentFiles, user);
|
|
2356
|
-
updateData[field.name] = newFiles;
|
|
2357
|
-
}
|
|
2358
|
-
}
|
|
2359
|
-
}
|
|
2360
|
-
|
|
2361
|
-
// 4. Validation adaptée pour patch ou edit (inchangé)
|
|
2362
|
-
if (!isPatch) {
|
|
2363
|
-
const dataToValidate = { ...existingDocs[0], ...updateData };
|
|
2364
|
-
await validateModelData(dataToValidate, model, false);
|
|
2365
|
-
} else {
|
|
2366
|
-
await validateModelData(updateData, model, true);
|
|
2367
|
-
}
|
|
2368
|
-
|
|
2369
|
-
// 5. Vérification des champs uniques (inchangé)
|
|
2370
|
-
const uniqueFields = model.fields.filter(f => f.unique);
|
|
2371
|
-
for (const field of uniqueFields) {
|
|
2372
|
-
if (updateData[field.name] !== undefined) {
|
|
2373
|
-
const existing = await collection.findOne({
|
|
2374
|
-
_user: user._user || user.username,
|
|
2375
|
-
_model: modelName,
|
|
2376
|
-
[field.name]: updateData[field.name],
|
|
2377
|
-
_id: {$nin: ids}
|
|
2378
|
-
});
|
|
2379
|
-
if (existing) {
|
|
2380
|
-
throw new Error(i18n.t("api.data.duplicateValue", { field: field.name, value: updateData[field.name] }));
|
|
2381
|
-
}
|
|
2382
|
-
}
|
|
2383
|
-
}
|
|
2384
|
-
|
|
2385
|
-
// 6. Traitement des relations (inchangé)
|
|
2386
|
-
const relationFields = model.fields.filter(f => f.type === 'relation');
|
|
2387
|
-
for (const field of relationFields) {
|
|
2388
|
-
if (updateData[field.name] !== undefined) {
|
|
2389
|
-
const relationValue = updateData[field.name];
|
|
2390
|
-
// Only process relations if the value is an object or an array containing at least one object.
|
|
2391
|
-
// An array of strings (ObjectIDs) should be passed through as-is for the update.
|
|
2392
|
-
let shouldProcessRelation = false;
|
|
2393
|
-
if (Array.isArray(relationValue)) {
|
|
2394
|
-
// If any item in the array is a plain object, we need to process the whole array
|
|
2395
|
-
// to handle potential nested creations or lookups.
|
|
2396
|
-
if (relationValue.some(item => isPlainObject(item))) {
|
|
2397
|
-
shouldProcessRelation = true;
|
|
2398
|
-
}
|
|
2399
|
-
} else if (isPlainObject(relationValue)) {
|
|
2400
|
-
shouldProcessRelation = true;
|
|
2401
|
-
}
|
|
2402
|
-
if (shouldProcessRelation) {
|
|
2403
|
-
const insertedIds = await pushDataUnsecure(relationValue, field.relation, user);
|
|
2404
|
-
updateData[field.name] = field.multiple ? insertedIds || [] : (insertedIds?.[0] || null);
|
|
2405
|
-
}
|
|
2406
|
-
}
|
|
2407
|
-
}
|
|
2408
|
-
|
|
2409
|
-
// 7. Application des filtres de champ (ex: hashage de mot de passe) (inchangé)
|
|
2410
|
-
for (const field of model.fields) {
|
|
2411
|
-
if (updateData[field.name] !== undefined && dataTypes[field.type]?.filter) {
|
|
2412
|
-
updateData[field.name] = await dataTypes[field.type].filter(
|
|
2413
|
-
field.type === 'file' ? null : updateData[field.name],
|
|
2414
|
-
field
|
|
2415
|
-
);
|
|
2416
|
-
}
|
|
2417
|
-
}
|
|
2418
|
-
|
|
2419
|
-
for (const field of model.fields) {
|
|
2420
|
-
if (field.type === 'relation' && field.relationFilter && updateData[field.name]) {
|
|
2421
|
-
|
|
2422
|
-
const relatedIds = Array.isArray(updateData[field.name])
|
|
2423
|
-
? updateData[field.name]
|
|
2424
|
-
: [updateData[field.name]];
|
|
2425
|
-
|
|
2426
|
-
// Préparer un filtre global : match si _id dans relatedIds ET respecte relationFilter
|
|
2427
|
-
const validationQuery = {
|
|
2428
|
-
$and: [
|
|
2429
|
-
{ $in: ['$_id', relatedIds.map(id => ({ $toObjectId: id }))] },
|
|
2430
|
-
field.relationFilter
|
|
2431
|
-
]
|
|
2432
|
-
};
|
|
2433
|
-
|
|
2434
|
-
const relatedDocs = await searchData({
|
|
2435
|
-
filter: validationQuery,
|
|
2436
|
-
model: field.relation,
|
|
2437
|
-
limit: relatedIds.length
|
|
2438
|
-
}, user);
|
|
2439
|
-
|
|
2440
|
-
if ((relatedDocs?.count || 0) !== relatedIds.length) {
|
|
2441
|
-
const invalidIds = relatedIds.filter(id =>
|
|
2442
|
-
!relatedDocs.data.some(doc => doc._id.toString() === id.toString())
|
|
2443
|
-
);
|
|
2444
|
-
throw new Error(
|
|
2445
|
-
i18n.t(
|
|
2446
|
-
'api.data.relationFilterFailed',
|
|
2447
|
-
'Les valeurs {{values}} pour le champ {{field}} ne respectent pas le filtre de relation défini.',
|
|
2448
|
-
{ field: field.name, values: invalidIds.join(', ') }
|
|
2449
|
-
)
|
|
2450
|
-
);
|
|
2451
|
-
}
|
|
2452
|
-
}
|
|
2453
|
-
}
|
|
2454
|
-
|
|
2455
|
-
// 8. Calcul du nouveau hash et préparation des données finales
|
|
2456
|
-
const finalStateForHash = { ...existingDocs[0], ...updateData };
|
|
2457
|
-
const newHash = getFieldValueHash(model, finalStateForHash);
|
|
2458
|
-
|
|
2459
|
-
const finalDataForSet = {
|
|
2460
|
-
...updateData,
|
|
2461
|
-
_hash: newHash
|
|
2462
|
-
};
|
|
2463
|
-
|
|
2464
|
-
// 9. *** CORRECTION LOGIQUE ***
|
|
2465
|
-
// On ne vérifie l'unicité que si le hash a réellement changé.
|
|
2466
|
-
if (newHash !== originalHash) {
|
|
2467
|
-
const hashCheck = await checkHash(user, model, newHash, existingDocs[0]._id.toString());
|
|
2468
|
-
if (hashCheck) {
|
|
2469
|
-
// Le nouvel état du document créerait un doublon.
|
|
2470
|
-
throw new Error(i18n.t("api.data.notUniqueData"));
|
|
2471
|
-
}
|
|
2472
|
-
}
|
|
2473
|
-
|
|
2474
|
-
// 10. Exécution de la mise à jour (inchangé)
|
|
2475
|
-
const bulkOps = [{ updateMany: { filter: {_id: {$in: ids}}, update: {$set: finalDataForSet} } }];
|
|
2476
|
-
const bulkResult = await collection.bulkWrite(bulkOps);
|
|
2477
|
-
const modifiedCount = bulkResult.modifiedCount || 0;
|
|
2478
|
-
|
|
2479
|
-
// Déclencher l'événement OnDataEdited avec les états avant/après
|
|
2480
|
-
if (modifiedCount > 0) {
|
|
2481
|
-
const updatedDocs = await collection.find({ _id: { $in: ids } }).toArray();
|
|
2482
|
-
await Event.Trigger("OnDataEdited", "event", "system", engine, {
|
|
2483
|
-
modelName,
|
|
2484
|
-
user,
|
|
2485
|
-
before: existingDocs, // Documents avant la modification
|
|
2486
|
-
after: updatedDocs // Documents après la modification
|
|
2487
|
-
});
|
|
2488
|
-
}
|
|
2489
|
-
|
|
2490
|
-
// 11. Tâches post-mise à jour (schedules, workflows) (inchangé)
|
|
2491
|
-
if (["workflowTrigger", "alert"].includes(modelName)) {
|
|
2492
|
-
await handleScheduledJobs(modelName, existingDocs, collection, finalDataForSet);
|
|
2493
|
-
}
|
|
2494
|
-
|
|
2495
|
-
if (triggerWorkflow && modifiedCount > 0) {
|
|
2496
|
-
const updatedDoc = await collection.findOne({_id: ids[0]});
|
|
2497
|
-
if (updatedDoc) {
|
|
2498
|
-
const proms = triggerWorkflows(updatedDoc, user, 'DataEdited')
|
|
2499
|
-
.catch(err => logger.error("[editData] Workflow trigger error:", err));
|
|
2500
|
-
if( waitForWorkflow ){
|
|
2501
|
-
await proms;
|
|
2502
|
-
}
|
|
2503
|
-
}
|
|
2504
|
-
}
|
|
2505
|
-
|
|
2506
|
-
return {
|
|
2507
|
-
success: true,
|
|
2508
|
-
modifiedCount
|
|
2509
|
-
};
|
|
2510
|
-
|
|
2511
|
-
} catch (error) {
|
|
2512
|
-
logger.error("Erreur lors de la mise à jour de la ressource :", error);
|
|
2513
|
-
return {success: false, error: error.message};
|
|
2514
|
-
}
|
|
2515
|
-
};
|
|
2516
|
-
|
|
2517
|
-
// Fonctions helper
|
|
2518
|
-
async function processFileArray(files, currentFiles, user) {
|
|
2519
|
-
const newFiles = await Promise.allSettled(
|
|
2520
|
-
Object.keys(files).map(f=>files[f]).map(async (file, i) => {
|
|
2521
|
-
const oldFile = currentFiles.find(f => f.name === file.name);
|
|
2522
|
-
if (oldFile && !file.newFile) return oldFile;
|
|
2523
|
-
if (file.guid) return file;
|
|
2524
|
-
if (!file.newFile) return Promise.reject();
|
|
2525
|
-
return await addFile(files[i], user);
|
|
2526
|
-
})
|
|
2527
|
-
).then(results => results.map(r => r.value).filter(Boolean));
|
|
2528
|
-
|
|
2529
|
-
// Suppression des anciens fichiers non réutilisés
|
|
2530
|
-
await Promise.allSettled(
|
|
2531
|
-
currentFiles
|
|
2532
|
-
.filter(f => !newFiles.some(nf => nf._id === f._id))
|
|
2533
|
-
.map(f => removeFile(f, user))
|
|
2534
|
-
);
|
|
2535
|
-
|
|
2536
|
-
return newFiles;
|
|
2537
|
-
}
|
|
2538
|
-
|
|
2539
|
-
async function handleScheduledJobs(modelName, existingDocs, collection, updateData) {
|
|
2540
|
-
for (const doc of existingDocs) {
|
|
2541
|
-
const jobId = `${modelName}_${doc._id}`;
|
|
2542
|
-
const existingJob = schedule.scheduledJobs[jobId];
|
|
2543
|
-
if (existingJob) existingJob.cancel();
|
|
2544
|
-
|
|
2545
|
-
const updatedDoc = {...doc, ...updateData};
|
|
2546
|
-
if (modelName === 'workflowTrigger' && updatedDoc.isActive && updatedDoc.cronExpression) {
|
|
2547
|
-
schedule.scheduleJob(jobId, updatedDoc.cronExpression, async () => {
|
|
2548
|
-
logger.info(`[Scheduled Job] Cron triggered for job ${jobId}`);
|
|
2549
|
-
await runScheduledJobWithDbLock(jobId, async () => {
|
|
2550
|
-
await sendSseToUser(updatedDoc._user, {
|
|
2551
|
-
type: 'cron_alert',
|
|
2552
|
-
triggerId: updatedDoc._id.toString(),
|
|
2553
|
-
triggerName: updatedDoc.name,
|
|
2554
|
-
timestamp: new Date().toISOString(),
|
|
2555
|
-
message: `L'alerte planifiée '${updatedDoc.name || 'Sans nom'}' a été déclenchée.`
|
|
2556
|
-
});
|
|
2557
|
-
}, updatedDoc.lockDurationMinutes || 5);
|
|
2558
|
-
});
|
|
2559
|
-
}
|
|
2560
|
-
|
|
2561
|
-
if (modelName === 'alert' && updatedDoc.isActive && updatedDoc.frequency) {
|
|
2562
|
-
await collection.updateOne({_id: updatedDoc._id}, {$set: {lastNotifiedAt: null}});
|
|
2563
|
-
schedule.scheduleJob(jobId, updatedDoc.frequency, () => runStatefulAlertJob(updatedDoc._id));
|
|
2564
|
-
}
|
|
2565
|
-
}
|
|
2566
|
-
}
|
|
2567
|
-
|
|
2568
|
-
|
|
2569
|
-
export const deleteData = async (modelName, filter, user ={}, triggerWorkflow, waitForWorkflow = false) => {
|
|
2570
|
-
|
|
2571
|
-
try {
|
|
2572
|
-
const collection = await getCollectionForUser(user);
|
|
2573
|
-
|
|
2574
|
-
// --- Début de la logique de suppression ---
|
|
2575
|
-
|
|
2576
|
-
// 1. Construire le filtre de base pour trouver les documents à supprimer
|
|
2577
|
-
let findFilter = [];
|
|
2578
|
-
if(user)
|
|
2579
|
-
findFilter.push({
|
|
2580
|
-
'$eq': ["$_user", user.username]
|
|
2581
|
-
});
|
|
2582
|
-
|
|
2583
|
-
// Ajouter le filtre par IDs si fourni
|
|
2584
|
-
if (Array.isArray(filter) && filter.length > 0) {
|
|
2585
|
-
findFilter.push({"$in": ["$_id", filter.map(m => new ObjectId(m))]});
|
|
2586
|
-
}
|
|
2587
|
-
|
|
2588
|
-
// Ajouter le filtre par nom de modèle si fourni (utile si 'filter' est utilisé seul)
|
|
2589
|
-
if (modelName)
|
|
2590
|
-
findFilter.push({
|
|
2591
|
-
'$eq': ["$_model", modelName]
|
|
2592
|
-
});
|
|
2593
|
-
|
|
2594
|
-
// Ajouter le filtre supplémentaire si fourni
|
|
2595
|
-
if (filter && typeof filter === 'object' && Object.keys(filter).length > 0) {
|
|
2596
|
-
// Fusionner prudemment le filtre supplémentaire
|
|
2597
|
-
// Attention: Si 'filter' contient des clés comme _id ou _user,
|
|
2598
|
-
// cela pourrait entrer en conflit. Une fusion plus robuste pourrait re nécessaire.
|
|
2599
|
-
findFilter.push(filter);
|
|
2600
|
-
}else{
|
|
2601
|
-
|
|
2602
|
-
}
|
|
2603
|
-
|
|
2604
|
-
// 2. Récupérer les documents à supprimer pour vérifier leur type et annuler les schedules
|
|
2605
|
-
const documentsToDelete = await collection.aggregate([{ $match: { $expr: { "$and": findFilter } } }]).toArray();
|
|
2606
|
-
|
|
2607
|
-
if (documentsToDelete.length === 0) {
|
|
2608
|
-
logger.info(`[deleteData] No documents found matching the criteria for user ${user?.username}.`);
|
|
2609
|
-
return ({ success: true, deletedCount: 0, message: "No documents found to delete." });
|
|
2610
|
-
}
|
|
2611
|
-
|
|
2612
|
-
const finalIdsToDelete = []; // IDs des documents qui seront effectivement supprimés
|
|
2613
|
-
|
|
2614
|
-
for (const docToDelete of documentsToDelete) {
|
|
2615
|
-
const deletePromises = [];
|
|
2616
|
-
const model = await getModel(docToDelete._model, user);
|
|
2617
|
-
for (const f of model.fields) {
|
|
2618
|
-
const fieldValue = docToDelete[f.name]; // Valeur du champ actuel depuis le document
|
|
2619
|
-
if (f.type === 'file') {
|
|
2620
|
-
if (typeof fieldValue === 'string' && fieldValue) { // fieldValue est un GUID
|
|
2621
|
-
deletePromises.push(removeFile(fieldValue, user));
|
|
2622
|
-
}
|
|
2623
|
-
} else if (f.type === 'array' && f.itemsType === 'file') {
|
|
2624
|
-
if (Array.isArray(fieldValue)) {
|
|
2625
|
-
for (const guidInArray of fieldValue) {
|
|
2626
|
-
if (typeof guidInArray === 'string' && guidInArray) {
|
|
2627
|
-
deletePromises.push(removeFile(guidInArray, user));
|
|
2628
|
-
}
|
|
2629
|
-
}
|
|
2630
|
-
}
|
|
2631
|
-
}
|
|
2632
|
-
}
|
|
2633
|
-
if (deletePromises.length > 0) {
|
|
2634
|
-
await Promise.allSettled(deletePromises);
|
|
2635
|
-
}
|
|
2636
|
-
|
|
2637
|
-
// Vérification des permissions (pour chaque document trouvé)
|
|
2638
|
-
if (user?.username !== 'demo' && isLocalUser(user) && (
|
|
2639
|
-
!await hasPermission(["API_ADMIN", "API_DELETE_DATA", "API_DELETE_DATA_" + docToDelete._model], user) ||
|
|
2640
|
-
await hasPermission(["API_DELETE_DATA_NOT_" + docToDelete._model], user))) {
|
|
2641
|
-
// Si l'utilisateur n'a pas la permission pour CE document spécifique, on l'ignore
|
|
2642
|
-
logger.warn(`[deleteData] User ${user.username} lacks permission to delete document ${docToDelete._id} of model ${docToDelete._model}. Skipping.`);
|
|
2643
|
-
continue; // Passe au document suivant
|
|
2644
|
-
}
|
|
2645
|
-
|
|
2646
|
-
// *** Ajout de l'annulation du schedule pour workflowTrigger ***
|
|
2647
|
-
if (docToDelete._model === 'workflowTrigger') {
|
|
2648
|
-
const jobId = `workflowTrigger_${docToDelete._id}`;
|
|
2649
|
-
const scheduledJob = schedule.scheduledJobs[jobId];
|
|
2650
|
-
if (scheduledJob) {
|
|
2651
|
-
scheduledJob.cancel();
|
|
2652
|
-
logger.info(`[deleteData] Cancelled scheduled job ${jobId} for deleted workflowTrigger ${docToDelete._id}.`);
|
|
2653
|
-
} else {
|
|
2654
|
-
// Ce n'est pas forcément une erreur si le trigger n'avait pas de cronExpression
|
|
2655
|
-
logger.debug(`[deleteData] No scheduled job found with ID ${jobId} to cancel for workflowTrigger ${docToDelete._id}.`);
|
|
2656
|
-
}
|
|
2657
|
-
}
|
|
2658
|
-
else if (docToDelete._model === 'alert') {
|
|
2659
|
-
const jobId = `alert_${docToDelete._id}`;
|
|
2660
|
-
const scheduledJob = schedule.scheduledJobs[jobId];
|
|
2661
|
-
if (scheduledJob) {
|
|
2662
|
-
scheduledJob.cancel();
|
|
2663
|
-
logger.info(`[deleteData] Cancelled scheduled job ${jobId} for deleted alert ${docToDelete._id}.`);
|
|
2664
|
-
}
|
|
2665
|
-
}
|
|
2666
|
-
// *** Fin de l'ajout ***
|
|
2667
|
-
|
|
2668
|
-
if( user ){
|
|
2669
|
-
// --- Logique existante pour gérer les relations ---
|
|
2670
|
-
const relatedModels = await modelsCollection.aggregate([
|
|
2671
|
-
{
|
|
2672
|
-
$match: {
|
|
2673
|
-
$and: [
|
|
2674
|
-
{"fields.relation": {$eq: docToDelete._model}}, // Utilise le modèle du document actuel
|
|
2675
|
-
{
|
|
2676
|
-
$and: [
|
|
2677
|
-
{_user: {$exists: true}},
|
|
2678
|
-
{ $or: [
|
|
2679
|
-
{_user: {$eq:user._user}},
|
|
2680
|
-
{_user: {$eq:user.username}}
|
|
2681
|
-
]}
|
|
2682
|
-
]
|
|
2683
|
-
}
|
|
2684
|
-
]
|
|
2685
|
-
}
|
|
2686
|
-
}
|
|
2687
|
-
]).toArray();
|
|
2688
|
-
|
|
2689
|
-
for (const relatedModel of relatedModels) {
|
|
2690
|
-
let relsSet = {}, pullOps = {}, filterConditions = [];
|
|
2691
|
-
relatedModel.fields.forEach(f => {
|
|
2692
|
-
if (f.type === 'relation' && f.relation === docToDelete._model) {
|
|
2693
|
-
const fieldCondition = { [f.name]: docToDelete._id.toString() };
|
|
2694
|
-
filterConditions.push(fieldCondition); // Condition pour trouver les documents liés
|
|
2695
|
-
|
|
2696
|
-
if (f.multiple) {
|
|
2697
|
-
if (!pullOps.$pull) pullOps.$pull = {};
|
|
2698
|
-
pullOps.$pull[f.name] = docToDelete._id.toString();
|
|
2699
|
-
} else {
|
|
2700
|
-
relsSet[f.name] = null;
|
|
2701
|
-
}
|
|
2702
|
-
}
|
|
2703
|
-
});
|
|
2704
|
-
|
|
2705
|
-
if (filterConditions.length > 0) {
|
|
2706
|
-
const updateFilter = {
|
|
2707
|
-
$and: [
|
|
2708
|
-
{ _user: { $exists: true } },
|
|
2709
|
-
{ _model: relatedModel.name },
|
|
2710
|
-
{ $or: [{ _user: user._user }, { _user: user.username }] },
|
|
2711
|
-
{ $or: filterConditions }
|
|
2712
|
-
]
|
|
2713
|
-
};
|
|
2714
|
-
|
|
2715
|
-
const updateOps = {};
|
|
2716
|
-
if (Object.keys(relsSet).length > 0) updateOps.$set = relsSet;
|
|
2717
|
-
if (pullOps.$pull && Object.keys(pullOps.$pull).length > 0) updateOps.$pull = pullOps.$pull;
|
|
2718
|
-
|
|
2719
|
-
if (Object.keys(updateOps).length > 0) {
|
|
2720
|
-
const elementsToUpdate = await collection.find(updateFilter).toArray();
|
|
2721
|
-
const updateResult = await collection.updateMany(updateFilter, updateOps);
|
|
2722
|
-
logger.debug(`[deleteData] Updated relations in model ${relatedModel.name} referencing ${docToDelete._id}. Modified: ${updateResult.modifiedCount}`);
|
|
2723
|
-
|
|
2724
|
-
if( triggerWorkflow ) {
|
|
2725
|
-
elementsToUpdate.forEach(e => {
|
|
2726
|
-
collection.findOne({_id: e._id}).then(updatedDoc => {
|
|
2727
|
-
if (updatedDoc) {
|
|
2728
|
-
triggerWorkflows(updatedDoc, user, 'DataEdited').catch(workflowError => {
|
|
2729
|
-
logger.error(`[deleteData] Async error triggering DataEdited workflow for ${updatedDoc._model} ID ${updatedDoc._id}:`, workflowError);
|
|
2730
|
-
});
|
|
2731
|
-
}
|
|
2732
|
-
});
|
|
2733
|
-
});
|
|
2734
|
-
}
|
|
2735
|
-
}
|
|
2736
|
-
}
|
|
2737
|
-
}
|
|
2738
|
-
|
|
2739
|
-
// --- Fin de la logique des relations ---
|
|
2740
|
-
|
|
2741
|
-
// Déclencher le workflow DataDeleted AVANT la suppression effective
|
|
2742
|
-
if( triggerWorkflow)
|
|
2743
|
-
{
|
|
2744
|
-
const prom = triggerWorkflows(docToDelete, user, 'DataDeleted').catch(workflowError => {
|
|
2745
|
-
logger.error(`[deleteData] Async error triggering DataDeleted workflow for ${docToDelete._model} ID ${docToDelete._id}:`, workflowError);
|
|
2746
|
-
});
|
|
2747
|
-
|
|
2748
|
-
if( waitForWorkflow){
|
|
2749
|
-
await prom;
|
|
2750
|
-
}
|
|
2751
|
-
}
|
|
2752
|
-
}
|
|
2753
|
-
|
|
2754
|
-
// Ajouter l'ID à la liste finale si la permission est accordée
|
|
2755
|
-
finalIdsToDelete.push(docToDelete._id);
|
|
2756
|
-
|
|
2757
|
-
} // Fin de la boucle sur documentsToDelete
|
|
2758
|
-
|
|
2759
|
-
// 3. Supprimer effectivement les documents (ceux pour lesquels on a la permission)
|
|
2760
|
-
let deletedCount = 0;
|
|
2761
|
-
if (finalIdsToDelete.length > 0) {
|
|
2762
|
-
const result = await collection.deleteMany({
|
|
2763
|
-
_id: { $in: finalIdsToDelete }
|
|
2764
|
-
// Le filtre _user est déjà implicite car on a fetch les documents de l'utilisateur
|
|
2765
|
-
});
|
|
2766
|
-
deletedCount = result.deletedCount;
|
|
2767
|
-
logger.info(`[deleteData] Successfully deleted ${deletedCount} documents for user ${user?.username}.`);
|
|
2768
|
-
} else {
|
|
2769
|
-
logger.info(`[deleteData] No documents to delete for user ${user?.username} after permission checks or matching criteria.`);
|
|
2770
|
-
}
|
|
2771
|
-
|
|
2772
|
-
const res = { success: true, deletedCount }
|
|
2773
|
-
const plugin = await Event.Trigger("OnDataDeleted", "event", "system", engine, {model:modelName, filter});
|
|
2774
|
-
await Event.Trigger("OnDataDeleted", "event", "user", {model:modelName, filter});
|
|
2775
|
-
return plugin || res;
|
|
2776
|
-
|
|
2777
|
-
} catch (error) {
|
|
2778
|
-
logger.error(`[deleteData] Error during deletion process for user ${user?.username}:`, error);
|
|
2779
|
-
// Renvoyer une structure d'erreur cohérente
|
|
2780
|
-
return ({ success: false, error: error.message || "An unexpected error occurred during deletion.", statusCode: error.statusCode || 500 });
|
|
2781
|
-
}
|
|
2782
|
-
}
|
|
2783
|
-
|
|
2784
|
-
export const searchData = async (query, user) => {
|
|
2785
|
-
const { page, limit, sort, model, ids, timeout, pack } = query; // Les filtres de la requête (attention aux injections MongoDB !)
|
|
2786
|
-
|
|
2787
|
-
if( user && user.username !== 'demo' && isLocalUser(user) && (
|
|
2788
|
-
!await hasPermission(["API_ADMIN", "API_SEARCH_DATA", "API_SEARCH_DATA_"+model], user) ||
|
|
2789
|
-
await hasPermission(["API_SEARCH_DATA_NOT_"+model], user))){
|
|
2790
|
-
throw new Error(i18n.t('api.permission.searchData'));
|
|
2791
|
-
}
|
|
2792
|
-
|
|
2793
|
-
const collection = await getCollectionForUser(user);
|
|
2794
|
-
const modelElement = await getModel(model, user);
|
|
2795
|
-
|
|
2796
|
-
const allIds = (ids || '').split(",").map(m => m.trim()).filter(Boolean).map(m => {
|
|
2797
|
-
return new ObjectId(m);
|
|
2798
|
-
});
|
|
2799
|
-
let l = Math.min(modelElement.maxRequestData || maxRequestData, limit ? parseInt(limit, 10) : maxRequestData);
|
|
2800
|
-
let p = parseInt(page, 10);
|
|
2801
|
-
let filter = query.filter || {};
|
|
2802
|
-
|
|
2803
|
-
let sortObj = {};
|
|
2804
|
-
sort?.split(',').forEach(s => {
|
|
2805
|
-
const v = s.split(':');
|
|
2806
|
-
sortObj[v[0] || s] = v[1] === 'DESC' ? -1 : 1;
|
|
2807
|
-
})
|
|
2808
|
-
if( !sort ){
|
|
2809
|
-
sortObj = {[modelElement.fields[0]?.name || '_id']: ['datetime','date'].includes(modelElement.fields[0].type) ? -1 : 1};
|
|
2810
|
-
}
|
|
2811
|
-
|
|
2812
|
-
let i = 0;
|
|
2813
|
-
const f = {...filter};
|
|
2814
|
-
|
|
2815
|
-
let depthParam = Math.max(1, Math.min(maxFilterDepth, typeof(query.depth) === 'string' ? parseInt(query.depth) : (typeof(query.depth) === 'number' ? query.depth : 1)));
|
|
2816
|
-
let autoExpand = typeof(query.autoExpand)==='undefined' || (typeof(query.autoExpand)==='string' && ['1','true'].includes(query.autoExpand.toLowerCase()));
|
|
2817
|
-
|
|
2818
|
-
const recursiveLookup = async (model, data, depth= 1, already = [], parentPath = '') => {
|
|
2819
|
-
|
|
2820
|
-
if( depth > depthParam )
|
|
2821
|
-
return [];
|
|
2822
|
-
|
|
2823
|
-
let pipelines = [], pipelinesLookups = [];
|
|
2824
|
-
let modelElement;
|
|
2825
|
-
try {
|
|
2826
|
-
modelElement = await getModel(model, user);
|
|
2827
|
-
} catch (e) {
|
|
2828
|
-
return [];
|
|
2829
|
-
}
|
|
2830
|
-
|
|
2831
|
-
let dataRelationF= [], dataNoRelation = {};
|
|
2832
|
-
let dte= changeValue(data, '$find', (name, d, topLevel) => {
|
|
2833
|
-
if (autoExpand)
|
|
2834
|
-
depthParam++;
|
|
2835
|
-
const field = modelElement.fields.find(f => f.name === name);
|
|
2836
|
-
if( !field || !name )
|
|
2837
|
-
return {};
|
|
2838
|
-
if( field.type === "relation") {
|
|
2839
|
-
const dt = {
|
|
2840
|
-
'$ne': [
|
|
2841
|
-
{
|
|
2842
|
-
'$filter': {
|
|
2843
|
-
'input': (depth === 1 ? "$" + name : "$this." + name),
|
|
2844
|
-
'as': 'this',
|
|
2845
|
-
'cond': d
|
|
2846
|
-
}
|
|
2847
|
-
}
|
|
2848
|
-
, []]
|
|
2849
|
-
};
|
|
2850
|
-
dataRelationF.push(dt);
|
|
2851
|
-
dataRelationF.push({ "$ne": [(depth===1 ? "$" + name : "$this." + name), null] })
|
|
2852
|
-
return {"$internal": {}};
|
|
2853
|
-
}
|
|
2854
|
-
dataNoRelation[name] = d;
|
|
2855
|
-
return {"$internal": d};
|
|
2856
|
-
});
|
|
2857
|
-
|
|
2858
|
-
dataNoRelation = changeValue(dte, '$internal', (name, d, topLevel) =>{
|
|
2859
|
-
return d;
|
|
2860
|
-
});
|
|
2861
|
-
|
|
2862
|
-
for (let fi of modelElement.fields.sort((s1,s2)=>{
|
|
2863
|
-
const v = s1.type ==='relation' ? 1 :0;
|
|
2864
|
-
const v2 = s2.type ==='relation' ? 1 : 0;
|
|
2865
|
-
|
|
2866
|
-
const t1 = s1.type === 'calculated' ? 1 : 0;
|
|
2867
|
-
const t2 = s2.type === 'calculated' ? 1 : 0;
|
|
2868
|
-
return v <= v2 ? -1 : (t1 <= t2 ? -1 : 1);
|
|
2869
|
-
} )) {
|
|
2870
|
-
|
|
2871
|
-
// **Circular Reference Check:**
|
|
2872
|
-
if (already.includes(fi.relation)) {
|
|
2873
|
-
// Skip the lookup if we've already processed this relation in the current chain.
|
|
2874
|
-
console.warn(`Skipping circular reference to model: ${fi.relation}`);
|
|
2875
|
-
continue;
|
|
2876
|
-
}
|
|
2877
|
-
const relSort = {};
|
|
2878
|
-
if (fi.type === 'relation' && depthParam !== 1) {
|
|
2879
|
-
delete f[fi.name];
|
|
2880
|
-
if (sortObj[fi.name]) {
|
|
2881
|
-
|
|
2882
|
-
const sortColumn = await getModel(fi.relation, user);
|
|
2883
|
-
let t = sortColumn.fields.find(f => f.asMain)?.name;
|
|
2884
|
-
if (!t) {
|
|
2885
|
-
let t = sortColumn.fields?.find((f) => f.type === 'string_t')?.name;
|
|
2886
|
-
if (t)
|
|
2887
|
-
relSort['items' + i + '.' + t] = sortObj[fi.name];
|
|
2888
|
-
else {
|
|
2889
|
-
|
|
2890
|
-
t = sortColumn.fields?.find((f) => f.type === 'string')?.name;
|
|
2891
|
-
if (t) {
|
|
2892
|
-
relSort['items' + i + '.' + t] = sortObj[fi.name];
|
|
2893
|
-
}
|
|
2894
|
-
}
|
|
2895
|
-
} else {
|
|
2896
|
-
relSort['items' + i + '.' + t] = sortObj[fi.name];
|
|
2897
|
-
}
|
|
2898
|
-
if (t) {
|
|
2899
|
-
delete sortObj[fi.name];
|
|
2900
|
-
}
|
|
2901
|
-
}
|
|
2902
|
-
|
|
2903
|
-
// Création du lookup si l'expand est activé
|
|
2904
|
-
++i;
|
|
2905
|
-
const lookup = {
|
|
2906
|
-
$lookup: {
|
|
2907
|
-
from: await getUserCollectionName(user),
|
|
2908
|
-
as: "items" + i,
|
|
2909
|
-
let: {
|
|
2910
|
-
dtid: {'$toString': '$_id'}, convertedId: '$' + fi.name
|
|
2911
|
-
},
|
|
2912
|
-
pipeline: [
|
|
2913
|
-
{
|
|
2914
|
-
$match: {
|
|
2915
|
-
$expr: {
|
|
2916
|
-
$and: [
|
|
2917
|
-
...[
|
|
2918
|
-
{$eq: ["$_model", fi.relation]},
|
|
2919
|
-
{$eq: ["$_user", user.username]},
|
|
2920
|
-
fi.multiple ? {
|
|
2921
|
-
$in: [{ $toString: "$_id" }, {
|
|
2922
|
-
$map: {
|
|
2923
|
-
input: { $ifNull: ["$$convertedId", []] }, // On utilise le tableau d'IDs, ou un tableau vide s'il est null
|
|
2924
|
-
as: "relationId",
|
|
2925
|
-
in: { $toString: "$$relationId" }
|
|
2926
|
-
}
|
|
2927
|
-
}]
|
|
2928
|
-
} : {
|
|
2929
|
-
$eq: [
|
|
2930
|
-
{ $toString: "$_id" },
|
|
2931
|
-
{ $convert: { input: '$$convertedId', to: "string", onError: '' } }
|
|
2932
|
-
]
|
|
2933
|
-
}
|
|
2934
|
-
]
|
|
2935
|
-
]
|
|
2936
|
-
}
|
|
2937
|
-
}
|
|
2938
|
-
},
|
|
2939
|
-
{$limit: maxRelationsPerData}
|
|
2940
|
-
]
|
|
2941
|
-
}
|
|
2942
|
-
};
|
|
2943
|
-
|
|
2944
|
-
pipelinesLookups.push(lookup);
|
|
2945
|
-
pipelinesLookups.push({$limit: Math.floor(maxTotalDataPerUser)});
|
|
2946
|
-
|
|
2947
|
-
// Construct the path for the current field
|
|
2948
|
-
const currentPath = parentPath ? `${parentPath}_${fi.name}` : fi.name;
|
|
2949
|
-
fi.path = currentPath;
|
|
2950
|
-
pipelinesLookups.push(
|
|
2951
|
-
{
|
|
2952
|
-
$addFields: {
|
|
2953
|
-
[`${fi.name}`]: (fi.multiple ? "$items" + i : {
|
|
2954
|
-
$cond: {
|
|
2955
|
-
if: {$gt: [{$size: {$ifNull: ["$items" + i, []]}}, 0]},
|
|
2956
|
-
then: "$items" + i,
|
|
2957
|
-
else: null
|
|
2958
|
-
}
|
|
2959
|
-
})
|
|
2960
|
-
}
|
|
2961
|
-
}
|
|
2962
|
-
);
|
|
2963
|
-
|
|
2964
|
-
//found = true;
|
|
2965
|
-
pipelinesLookups.push(
|
|
2966
|
-
{$project: {['items' + i]: 0}}
|
|
2967
|
-
);
|
|
2968
|
-
|
|
2969
|
-
const nextAlready = [...already, fi.relation];
|
|
2970
|
-
if (Object.keys(relSort).length) {
|
|
2971
|
-
pipelinesLookups.push(
|
|
2972
|
-
{$sort: relSort}
|
|
2973
|
-
);
|
|
2974
|
-
}
|
|
2975
|
-
|
|
2976
|
-
let find = dte[fi.name] || [];
|
|
2977
|
-
|
|
2978
|
-
const rec = await recursiveLookup(fi.relation, find, depth + 1, nextAlready, currentPath);
|
|
2979
|
-
|
|
2980
|
-
if (rec.length) {
|
|
2981
|
-
lookup['$lookup']['pipeline'] = lookup['$lookup']['pipeline'].concat(rec);
|
|
2982
|
-
}
|
|
2983
|
-
|
|
2984
|
-
lookup['$lookup']['pipeline'].push(
|
|
2985
|
-
{$project: {_model: 0}}
|
|
2986
|
-
);
|
|
2987
|
-
lookup['$lookup']['pipeline'].push(
|
|
2988
|
-
{$project: {_user: 0}}
|
|
2989
|
-
);
|
|
2990
|
-
|
|
2991
|
-
}else if (fi.type === 'file') {
|
|
2992
|
-
// Logique pour enrichir un champ fichier unique
|
|
2993
|
-
|
|
2994
|
-
// Stage 1: Lookup file details from the 'files' collection
|
|
2995
|
-
pipelinesLookups.push({
|
|
2996
|
-
$lookup: {
|
|
2997
|
-
from: "files", // The global collection where file metadata is stored
|
|
2998
|
-
let: { fileGuid: '$' + fi.name }, // The GUID string from the current document's field
|
|
2999
|
-
pipeline: [
|
|
3000
|
-
{
|
|
3001
|
-
$match: {
|
|
3002
|
-
$expr: {
|
|
3003
|
-
$and: [
|
|
3004
|
-
{ $eq: ['$guid', '$$fileGuid'] },
|
|
3005
|
-
{ $eq: ['$user', user.username] }
|
|
3006
|
-
]
|
|
3007
|
-
}
|
|
3008
|
-
}
|
|
3009
|
-
},
|
|
3010
|
-
{ $limit: 1 } // GUIDs should be unique, so limit to 1
|
|
3011
|
-
],
|
|
3012
|
-
as: fi.name + "_details_temp" // Temporary field to store the lookup result (an array)
|
|
3013
|
-
}
|
|
3014
|
-
});
|
|
3015
|
-
|
|
3016
|
-
// Stage 2: Replace the original GUID string with the fetched file object (or null if not found)
|
|
3017
|
-
pipelinesLookups.push({
|
|
3018
|
-
$addFields: {
|
|
3019
|
-
[fi.name]: {
|
|
3020
|
-
// $lookup returns an array, take the first element.
|
|
3021
|
-
// If lookup result is empty or null, set the field to null.
|
|
3022
|
-
$ifNull: [ { $first: '$' + fi.name + "_details_temp" }, null ]
|
|
3023
|
-
}
|
|
3024
|
-
}
|
|
3025
|
-
});
|
|
3026
|
-
|
|
3027
|
-
// Stage 3: Clean up the temporary lookup field
|
|
3028
|
-
pipelinesLookups.push({
|
|
3029
|
-
$project: {
|
|
3030
|
-
[fi.name + "_details_temp"]: 0
|
|
3031
|
-
}
|
|
3032
|
-
});
|
|
3033
|
-
} else if (fi.type === 'array' && fi.itemsType === 'file' && depthParam !== 1) {
|
|
3034
|
-
// This field (e.g., 'myImageGallery') stores an array of GUID strings: ["guid1", "guid2"]
|
|
3035
|
-
pipelinesLookups.push(
|
|
3036
|
-
{
|
|
3037
|
-
$lookup: {
|
|
3038
|
-
from: "files", // The global collection where file metadata is stored
|
|
3039
|
-
let: { localGuidsArray: '$' + fi.name }, // The array of GUID strings from the current document
|
|
3040
|
-
pipeline: [
|
|
3041
|
-
{
|
|
3042
|
-
$match: {
|
|
3043
|
-
$expr: {
|
|
3044
|
-
// Match documents in "files" collection where 'guid' is in the '$$localGuidsArray'
|
|
3045
|
-
$in: ['$guid', { $ifNull: ['$$localGuidsArray', []] }] // Handle null or missing array
|
|
3046
|
-
}
|
|
3047
|
-
}
|
|
3048
|
-
}
|
|
3049
|
-
// Optional: Project only necessary fields from the "files" collection if needed
|
|
3050
|
-
// {
|
|
3051
|
-
// $project: {
|
|
3052
|
-
// _id: 0, // Exclude MongoDB's _id from the 'files' collection documents
|
|
3053
|
-
// // mainUser: 0, // Example: if you don't need these in the result
|
|
3054
|
-
// // user: 0,
|
|
3055
|
-
// // _model:0, // The _model "privateFile" might not be useful here
|
|
3056
|
-
// // Keep: guid, filename (as name), mimetype, size, timestamp etc.
|
|
3057
|
-
// }
|
|
3058
|
-
// }
|
|
3059
|
-
],
|
|
3060
|
-
as: fi.name + "_details_temp" // Temporary field to store the array of matched file detail objects
|
|
3061
|
-
}
|
|
3062
|
-
},
|
|
3063
|
-
// The following $addFields and $project stages are what you had
|
|
3064
|
-
// in your fi.type === 'file' block, and they are correct for this array scenario.
|
|
3065
|
-
{
|
|
3066
|
-
$addFields: {
|
|
3067
|
-
[fi.name]: { // Remplacer le tableau de chaînes GUID par un tableau d'objets fichiers détaillés
|
|
3068
|
-
$ifNull: [ // Gérer le cas où le champ fi.name est null (original array of GUIDs)
|
|
3069
|
-
{
|
|
3070
|
-
$map: {
|
|
3071
|
-
input: '$' + fi.name, // Itérer sur le tableau original de chaînes GUID
|
|
3072
|
-
as: "originalGuidString", // Each element from the input array (a GUID string)
|
|
3073
|
-
in: {
|
|
3074
|
-
$let: {
|
|
3075
|
-
vars: {
|
|
3076
|
-
// Trouver le détail correspondant dans _details_temp par GUID
|
|
3077
|
-
matchedDetail: {
|
|
3078
|
-
$arrayElemAt: [
|
|
3079
|
-
{
|
|
3080
|
-
$filter: {
|
|
3081
|
-
input: '$' + fi.name + "_details_temp", // Use the result from the $lookup above
|
|
3082
|
-
as: "detailFile", // Each document from _details_temp
|
|
3083
|
-
cond: { $eq: ["$$detailFile.guid", "$$originalGuidString"] }
|
|
3084
|
-
}
|
|
3085
|
-
},
|
|
3086
|
-
0 // Take the first match (GUIDs should be unique in "files")
|
|
3087
|
-
]
|
|
3088
|
-
}
|
|
3089
|
-
},
|
|
3090
|
-
in: {
|
|
3091
|
-
$cond: {
|
|
3092
|
-
if: '$$matchedDetail', // Si des détails ont été trouvés
|
|
3093
|
-
then: '$$matchedDetail', // Utiliser l'objet détaillé complet
|
|
3094
|
-
else: { // Si aucun détail trouvé pour ce GUID (e.g., broken reference)
|
|
3095
|
-
guid: '$$originalGuidString', // Conserver le GUID original
|
|
3096
|
-
name: null, // Ou une valeur par défaut comme "Fichier inconnu"
|
|
3097
|
-
_error: "File details not found"
|
|
3098
|
-
// Ou simplement: '$$originalGuidString' si vous voulez juste garder la chaîne
|
|
3099
|
-
}
|
|
3100
|
-
}
|
|
3101
|
-
}
|
|
3102
|
-
}
|
|
3103
|
-
}
|
|
3104
|
-
}
|
|
3105
|
-
},
|
|
3106
|
-
[] // Si le champ original fi.name était null, le résultat est un tableau vide
|
|
3107
|
-
]
|
|
3108
|
-
}
|
|
3109
|
-
}
|
|
3110
|
-
},
|
|
3111
|
-
{
|
|
3112
|
-
$project: { // Nettoyer le champ temporaire
|
|
3113
|
-
[fi.name + "_details_temp"]: 0
|
|
3114
|
-
}
|
|
3115
|
-
}
|
|
3116
|
-
);
|
|
3117
|
-
} else if (fi.type === 'calculated' && fi.calculation && fi.calculation.pipeline && fi.calculation.final) {
|
|
3118
|
-
const calcPipelineAbstract = fi.calculation.pipeline;
|
|
3119
|
-
const calcFinalFieldName = fi.calculation.final;
|
|
3120
|
-
const tempLookupsForThisCalcField = []; // Pour stocker les noms 'as' des lookups de CE champ calculé
|
|
3121
|
-
|
|
3122
|
-
// Ajouter les étapes $lookup définies par le calcul
|
|
3123
|
-
if (calcPipelineAbstract.lookups && calcPipelineAbstract.lookups.length > 0) {
|
|
3124
|
-
for (const lookupDef of calcPipelineAbstract.lookups) {
|
|
3125
|
-
// ... (votre logique existante de vérification de foreignModel et localField) ...
|
|
3126
|
-
// Assurez-vous que cette logique est robuste comme discuté précédemment.
|
|
3127
|
-
// Si une erreur se produit ici (foreignModel non trouvé, etc.),
|
|
3128
|
-
// vous ajoutez déjà un $addFields pour initialiser lookupDef.as à null/[]
|
|
3129
|
-
// et vous faites 'continue'. C'est bien.
|
|
3130
|
-
|
|
3131
|
-
// Si tout va bien, on construit le lookup :
|
|
3132
|
-
const targetCollectionName = await getUserCollectionName(user);
|
|
3133
|
-
const localFieldValueInPipeline = `$${lookupDef.localField}`;
|
|
3134
|
-
|
|
3135
|
-
// Vérification basique du localField (déjà présente dans votre code précédent)
|
|
3136
|
-
if (!lookupDef.localField || typeof lookupDef.localField !== 'string' || lookupDef.localField.trim() === '') {
|
|
3137
|
-
logger.warn(`[Calculated Field Error] ... localField ... invalide ...`);
|
|
3138
|
-
pipelinesLookups.push({$addFields: {[lookupDef.as]: lookupDef.isMultiple ? [] : null}});
|
|
3139
|
-
continue;
|
|
3140
|
-
}
|
|
3141
|
-
|
|
3142
|
-
const mongoLookupStage = {
|
|
3143
|
-
$lookup: {
|
|
3144
|
-
from: targetCollectionName,
|
|
3145
|
-
let: {local_val: localFieldValueInPipeline},
|
|
3146
|
-
pipeline: [
|
|
3147
|
-
{
|
|
3148
|
-
$match: {
|
|
3149
|
-
$expr: {
|
|
3150
|
-
$and: [
|
|
3151
|
-
{$eq: ["$_model", lookupDef.foreignModel]},
|
|
3152
|
-
{$eq: ["$_user", user.username]},
|
|
3153
|
-
lookupDef.isMultiple
|
|
3154
|
-
? {$in: [{$toString: "$_id"}, {$ifNull: ["$$local_val", []]}]}
|
|
3155
|
-
: {$eq: [{$toString: "$_id"}, {$ifNull: ["$$local_val", null]}]}
|
|
3156
|
-
]
|
|
3157
|
-
}
|
|
3158
|
-
}
|
|
3159
|
-
}
|
|
3160
|
-
// Optionnel: Projeter uniquement les champs nécessaires
|
|
3161
|
-
],
|
|
3162
|
-
as: lookupDef.as
|
|
3163
|
-
}
|
|
3164
|
-
};
|
|
3165
|
-
pipelinesLookups.push(mongoLookupStage);
|
|
3166
|
-
tempLookupsForThisCalcField.push(lookupDef.as); // Suivre ce champ temporaire
|
|
3167
|
-
|
|
3168
|
-
if (!lookupDef.isMultiple) {
|
|
3169
|
-
pipelinesLookups.push({
|
|
3170
|
-
$unwind: {
|
|
3171
|
-
path: `$${lookupDef.as}`,
|
|
3172
|
-
preserveNullAndEmptyArrays: true
|
|
3173
|
-
}
|
|
3174
|
-
});
|
|
3175
|
-
}
|
|
3176
|
-
}
|
|
3177
|
-
}
|
|
3178
|
-
|
|
3179
|
-
// Ajouter l'étape $addFields pour les calculs eux-mêmes
|
|
3180
|
-
if (calcPipelineAbstract.addFields && Object.keys(calcPipelineAbstract.addFields).length > 0) {
|
|
3181
|
-
const addFields = Object.keys(calcPipelineAbstract.addFields).map(m => ({ $addFields: { [m] : calcPipelineAbstract.addFields[m] } }));
|
|
3182
|
-
pipelinesLookups = pipelinesLookups.concat(addFields);
|
|
3183
|
-
}
|
|
3184
|
-
|
|
3185
|
-
// S'assurer que le champ final du calcul (calcFinalFieldName) est bien accessible
|
|
3186
|
-
// sous le nom du champ du modèle (fi.name).
|
|
3187
|
-
if (calcFinalFieldName !== fi.name) {
|
|
3188
|
-
pipelinesLookups.push({$addFields: {[fi.name]: `$${calcFinalFieldName}`}});
|
|
3189
|
-
}
|
|
3190
|
-
|
|
3191
|
-
// --- NOUVEAU : Supprimer les champs de lookup temporaires pour CE champ calculé ---
|
|
3192
|
-
if (tempLookupsForThisCalcField.length > 0) {
|
|
3193
|
-
const unsetProjection = {};
|
|
3194
|
-
for (const tempField of tempLookupsForThisCalcField) {
|
|
3195
|
-
// On peut supprimer tous les champs __calc_lookup_... car le CalculationBuilder
|
|
3196
|
-
// empêche que outputAlias (et donc calcFinalFieldName, et donc fi.name)
|
|
3197
|
-
// soit un de ces champs temporaires.
|
|
3198
|
-
unsetProjection[tempField] = 0; // 0 signifie supprimer/exclure le champ
|
|
3199
|
-
}
|
|
3200
|
-
if (Object.keys(unsetProjection).length > 0) {
|
|
3201
|
-
pipelinesLookups.push({$project: unsetProjection});
|
|
3202
|
-
}
|
|
3203
|
-
}
|
|
3204
|
-
} else if (fi.type === 'array') {
|
|
3205
|
-
// Handle array filtering here
|
|
3206
|
-
if (data[fi.name]) {
|
|
3207
|
-
pipelines.push({
|
|
3208
|
-
$match: {
|
|
3209
|
-
$expr: {
|
|
3210
|
-
$in: [data[fi.name], '$' + fi.name] // Check if the array contains the value
|
|
3211
|
-
}
|
|
3212
|
-
}
|
|
3213
|
-
});
|
|
3214
|
-
}
|
|
3215
|
-
} else if (data[fi.name]) {
|
|
3216
|
-
if (typeof (data[fi.name]) === 'string') {
|
|
3217
|
-
pipelines.push({
|
|
3218
|
-
$match: {
|
|
3219
|
-
$expr: {
|
|
3220
|
-
$eq: ['$' + fi.name, data[fi.name]]
|
|
3221
|
-
}
|
|
3222
|
-
}
|
|
3223
|
-
})
|
|
3224
|
-
}
|
|
3225
|
-
}
|
|
3226
|
-
}
|
|
3227
|
-
|
|
3228
|
-
|
|
3229
|
-
let addFields=[];
|
|
3230
|
-
/*modelElement.fields.forEach(field => {
|
|
3231
|
-
if( field.type==='relation' && !field.multiple && depthParam !== 1 && (dataRelationF.length)){
|
|
3232
|
-
addFields.push(
|
|
3233
|
-
{$addFields: {[`${field.name}`]: {$first: '$'+field.name }}}
|
|
3234
|
-
)
|
|
3235
|
-
}
|
|
3236
|
-
})*/
|
|
3237
|
-
return pipelines.concat([{$match: { '_pack': pack ? pack : { $exists: false }}}, {$match:{$expr:dataNoRelation}}], pipelinesLookups, addFields, [{$match:{$expr:{$and:dataRelationF}}}]);
|
|
3238
|
-
};
|
|
3239
|
-
|
|
3240
|
-
let pipelines = [];
|
|
3241
|
-
if( allIds.length ){
|
|
3242
|
-
const id = {$in: ["$_id", allIds.map(m => new ObjectId(m))]};
|
|
3243
|
-
pipelines.push({
|
|
3244
|
-
$match: { $expr: id }
|
|
3245
|
-
});
|
|
3246
|
-
|
|
3247
|
-
}else {
|
|
3248
|
-
|
|
3249
|
-
pipelines.push(
|
|
3250
|
-
{
|
|
3251
|
-
$match: {
|
|
3252
|
-
$expr: {
|
|
3253
|
-
$and: [{$eq: ["$_model", modelElement.name]},
|
|
3254
|
-
{$eq: ["$_user", user.username]}]
|
|
3255
|
-
}
|
|
3256
|
-
}
|
|
3257
|
-
}
|
|
3258
|
-
)
|
|
3259
|
-
}
|
|
3260
|
-
|
|
3261
|
-
pipelines = pipelines.concat(await recursiveLookup(model, filter, 1, [])) ;
|
|
3262
|
-
if( depthParam ) {
|
|
3263
|
-
pipelines.push({$project: {_user: 0}});
|
|
3264
|
-
pipelines.push({$project: {_model: 0}});
|
|
3265
|
-
}
|
|
3266
|
-
|
|
3267
|
-
//console.log(util.inspect(pipelines, false, 29, true));
|
|
3268
|
-
|
|
3269
|
-
// 4. Exécuter la pipeline
|
|
3270
|
-
const ts = parseInt(timeout, 10)/2.0 || searchRequestTimeout;
|
|
3271
|
-
const count = await collection.aggregate([...pipelines, { $count: "count" }]).maxTimeMS(ts).toArray();
|
|
3272
|
-
let prom = collection.aggregate(pipelines).maxTimeMS(ts);
|
|
3273
|
-
|
|
3274
|
-
if( Object.keys(sortObj).length > 0 ) {
|
|
3275
|
-
prom.sort(sortObj);
|
|
3276
|
-
}
|
|
3277
|
-
prom.skip(p ? (p-1) * l : 0).limit(l);
|
|
3278
|
-
let data = await prom.toArray();
|
|
3279
|
-
data = await handleFields(modelElement, data, user);
|
|
3280
|
-
|
|
3281
|
-
const res = {data, count: count[0]?.count || 0};
|
|
3282
|
-
const plugin = await Event.Trigger("OnDataSearched", "event", "system", engine, {data, count: count[0]?.count});
|
|
3283
|
-
await Event.Trigger("OnDataSearched", "event", "user", plugin || {data, count: count[0]?.count});
|
|
3284
|
-
return plugin || res;
|
|
3285
|
-
}
|
|
3286
|
-
|
|
3287
|
-
export const importData = async(options, files, user) => {
|
|
3288
|
-
|
|
3289
|
-
if( !(isDemoUser(user) && Config.Get("useDemoAccounts")) && isLocalUser(user) && !await hasPermission(["API_ADMIN", "API_IMPORT_DATA"], user)){
|
|
3290
|
-
return ({ success: false, error: "API_IMPORT_DATA permission needed." });
|
|
3291
|
-
}
|
|
3292
|
-
|
|
3293
|
-
const file = files.file;
|
|
3294
|
-
const hasHeaders = options.hasHeaders ? options.hasHeaders === 'true' : true;
|
|
3295
|
-
const csvHeadersString = options.csvHeaders;
|
|
3296
|
-
|
|
3297
|
-
if (!file) {
|
|
3298
|
-
return ({ success: false, error: "No file uploaded." });
|
|
3299
|
-
}
|
|
3300
|
-
|
|
3301
|
-
const importJobId = new ObjectId().toString();
|
|
3302
|
-
importJobs[importJobId] = {
|
|
3303
|
-
userId: user.username,
|
|
3304
|
-
status: 'pending',
|
|
3305
|
-
totalRecords: 0,
|
|
3306
|
-
processedRecords: 0,
|
|
3307
|
-
errors: [],
|
|
3308
|
-
jobId: importJobId // Inclure l'ID de la tâche dans son état
|
|
3309
|
-
};
|
|
3310
|
-
|
|
3311
|
-
const importJob = importJobs[importJobId];
|
|
3312
|
-
// Excuter le reste de la logique d'importation en arrière-plan
|
|
3313
|
-
(async () => {
|
|
3314
|
-
let fileProcessed = false;
|
|
3315
|
-
const importResults = { success: true, counts: {}, errors: [] }; // Pour collecter les erreurs internes
|
|
3316
|
-
|
|
3317
|
-
try {
|
|
3318
|
-
const fileContent = fs.readFileSync(file.path);
|
|
3319
|
-
// --- DÉBUT DE LA MODIFICATION ---
|
|
3320
|
-
// La variable allProcessedData est maintenant déclarée à l'intérieur de la boucle
|
|
3321
|
-
// let allProcessedData = [];
|
|
3322
|
-
// let modelNameForImport = '';
|
|
3323
|
-
// --- FIN DE LA MODIFICATION ---
|
|
3324
|
-
if( !file.name ){
|
|
3325
|
-
throw new Error("No file provided.");
|
|
3326
|
-
}
|
|
3327
|
-
if (file.type === 'application/json' || file.name.endsWith('.json')) {
|
|
3328
|
-
fileProcessed = true;
|
|
3329
|
-
const jsonData = await runImportExportWorker('parse-json', { fileContent: fileContent.toString() });
|
|
3330
|
-
|
|
3331
|
-
// --- Logique d'importation des modèles (inchangée) ---
|
|
3332
|
-
if (jsonData.models && Array.isArray(jsonData.models)) {
|
|
3333
|
-
logger.info(`[Model Import] Found ${jsonData.models.length} models to import for user ${user.username}.`);
|
|
3334
|
-
const userModels = await modelsCollection.find({ _user: user.username }).toArray();
|
|
3335
|
-
const userModelNames = userModels.map(m => m.name);
|
|
3336
|
-
|
|
3337
|
-
for (const modelToInstall of jsonData.models) {
|
|
3338
|
-
try {
|
|
3339
|
-
const modelName = modelToInstall.name;
|
|
3340
|
-
if (!modelName) {
|
|
3341
|
-
throw new Error("Model definition in import file is missing a 'name'.");
|
|
3342
|
-
}
|
|
3343
|
-
|
|
3344
|
-
if (userModelNames.includes(modelName)) {
|
|
3345
|
-
logger.debug(`[Model Import] Skipping '${modelName}': already exists for user.`);
|
|
3346
|
-
continue;
|
|
3347
|
-
}
|
|
3348
|
-
|
|
3349
|
-
const modelData = { ...modelToInstall };
|
|
3350
|
-
delete modelData._id;
|
|
3351
|
-
modelData._user = user.username;
|
|
3352
|
-
modelData.locked = false;
|
|
3353
|
-
if (modelData.fields) {
|
|
3354
|
-
modelData.fields.forEach(f => {
|
|
3355
|
-
delete f._id;
|
|
3356
|
-
f.locked = false;
|
|
3357
|
-
});
|
|
3358
|
-
}
|
|
3359
|
-
|
|
3360
|
-
await validateModelStructure(modelData);
|
|
3361
|
-
await modelsCollection.insertOne(modelData);
|
|
3362
|
-
logger.info(`[Model Import] Successfully imported model '${modelName}' for user ${user.username}.`);
|
|
3363
|
-
} catch (e) {
|
|
3364
|
-
const errorMsg = `Failed to import model '${modelToInstall.name || 'N/A'}': ${e.message}`;
|
|
3365
|
-
logger.error(`[Model Import] ${errorMsg}`);
|
|
3366
|
-
importResults.errors.push(errorMsg);
|
|
3367
|
-
importResults.success = false;
|
|
3368
|
-
}
|
|
3369
|
-
}
|
|
3370
|
-
}
|
|
3371
|
-
|
|
3372
|
-
const dataToProcess = jsonData.data || jsonData;
|
|
3373
|
-
const modelsToProcess = [];
|
|
3374
|
-
|
|
3375
|
-
if (Array.isArray(dataToProcess)) {
|
|
3376
|
-
const modelNameForImport = options.model;
|
|
3377
|
-
if (!modelNameForImport) {
|
|
3378
|
-
importResults.errors.push("Model name is required in the request body when JSON is an array.");
|
|
3379
|
-
importResults.success = false;
|
|
3380
|
-
} else {
|
|
3381
|
-
modelsToProcess.push({ name: modelNameForImport, data: dataToProcess });
|
|
3382
|
-
}
|
|
3383
|
-
} else if (typeof dataToProcess === 'object' && dataToProcess !== null) {
|
|
3384
|
-
for (const modelKey in dataToProcess) {
|
|
3385
|
-
if (modelKey === 'models') continue;
|
|
3386
|
-
if (Object.prototype.hasOwnProperty.call(dataToProcess, modelKey)) {
|
|
3387
|
-
if (Array.isArray(dataToProcess[modelKey])) {
|
|
3388
|
-
modelsToProcess.push({ name: modelKey, data: dataToProcess[modelKey] });
|
|
3389
|
-
} else {
|
|
3390
|
-
const errorMsg = `Data for model '${modelKey}' in JSON object is not an array. Skipping.`;
|
|
3391
|
-
logger.warn(`[Import JSON Object] ${errorMsg}`);
|
|
3392
|
-
importResults.errors.push(errorMsg);
|
|
3393
|
-
importResults.success = false;
|
|
3394
|
-
}
|
|
3395
|
-
}
|
|
3396
|
-
}
|
|
3397
|
-
} else {
|
|
3398
|
-
importResults.errors.push("Invalid JSON file structure. Expecting an array of data, or an object where keys are model names and values are arrays of data.");
|
|
3399
|
-
importResults.success = false;
|
|
3400
|
-
}
|
|
3401
|
-
|
|
3402
|
-
if (modelsToProcess.length === 0 && importResults.errors.length === 0) {
|
|
3403
|
-
importResults.errors.push("No data found to process in JSON file.");
|
|
3404
|
-
importResults.success = false;
|
|
3405
|
-
}
|
|
3406
|
-
|
|
3407
|
-
// --- DÉBUT DE LA MODIFICATION PRINCIPALE ---
|
|
3408
|
-
// Mettre à jour le statut et le nombre total d'enregistrements avant la boucle
|
|
3409
|
-
if (importResults.success && modelsToProcess.length > 0) {
|
|
3410
|
-
importJobs[importJobId].totalRecords = modelsToProcess.reduce((acc, model) => acc + model.data.length, 0);
|
|
3411
|
-
importJobs[importJobId].status = 'processing';
|
|
3412
|
-
}
|
|
3413
|
-
|
|
3414
|
-
// Boucler sur CHAQUE modèle trouvé dans le fichier JSON
|
|
3415
|
-
for (const modelData of modelsToProcess) {
|
|
3416
|
-
const modelName = modelData.name;
|
|
3417
|
-
const dataArray = modelData.data;
|
|
3418
|
-
|
|
3419
|
-
if (dataArray.length === 0) {
|
|
3420
|
-
logger.info(`[Import Job ${importJobId}] Skipping model '${modelName}' as it has no data.`);
|
|
3421
|
-
continue;
|
|
3422
|
-
}
|
|
3423
|
-
|
|
3424
|
-
logger.info(`[Import Job ${importJobId}] Processing ${dataArray.length} records for model '${modelName}'.`);
|
|
3425
|
-
|
|
3426
|
-
try {
|
|
3427
|
-
const modelDef = await getModel(modelName, user);
|
|
3428
|
-
if (!modelDef) {
|
|
3429
|
-
throw new Error(i18n.t('api.model.notFound', { model: modelName }));
|
|
3430
|
-
}
|
|
3431
|
-
|
|
3432
|
-
const allProcessedData = convertDataTypes(dataArray, modelDef.fields, 'json');
|
|
3433
|
-
|
|
3434
|
-
// Logique de découpage en lots pour le modèle actuel
|
|
3435
|
-
for (let i = 0; i < allProcessedData.length; i += IMPORT_CHUNK_SIZE) {
|
|
3436
|
-
const chunk = allProcessedData.slice(i, i + IMPORT_CHUNK_SIZE);
|
|
3437
|
-
try {
|
|
3438
|
-
const insertedIdsArray = await pushDataUnsecure(chunk, modelName, user, {});
|
|
3439
|
-
if (insertedIdsArray && insertedIdsArray.length > 0) {
|
|
3440
|
-
importJobs[importJobId].processedRecords += insertedIdsArray.length;
|
|
3441
|
-
logger.debug(`[Import Job ${importJobId}] Processed chunk for '${modelName}': ${insertedIdsArray.length} records. Total processed: ${importJobs[importJobId].processedRecords}`);
|
|
3442
|
-
await sendSseToUser(user.username, {
|
|
3443
|
-
type: 'import_progress',
|
|
3444
|
-
job: importJobs[importJobId]
|
|
3445
|
-
});
|
|
3446
|
-
}
|
|
3447
|
-
} catch (chunkError) {
|
|
3448
|
-
console.log(chunkError.stack);
|
|
3449
|
-
const errorMsg = `[Import Job ${importJobId}] Error on chunk for model '${modelName}': ${chunkError.message}`;
|
|
3450
|
-
logger.error(errorMsg);
|
|
3451
|
-
importResults.errors.push(errorMsg);
|
|
3452
|
-
importJobs[importJobId].errors.push(errorMsg);
|
|
3453
|
-
importResults.success = false;
|
|
3454
|
-
}
|
|
3455
|
-
|
|
3456
|
-
if (i + IMPORT_CHUNK_SIZE < allProcessedData.length) {
|
|
3457
|
-
await delay(IMPORT_CHUNK_DELAY_MS);
|
|
3458
|
-
}
|
|
3459
|
-
}
|
|
3460
|
-
importResults.counts[modelName] = (importResults.counts[modelName] || 0) + allProcessedData.length;
|
|
3461
|
-
|
|
3462
|
-
} catch (modelProcessingError) {
|
|
3463
|
-
const errorMsg = `[Import Job ${importJobId}] Failed to process model '${modelName}': ${modelProcessingError.message}`;
|
|
3464
|
-
logger.error(errorMsg);
|
|
3465
|
-
importResults.errors.push(errorMsg);
|
|
3466
|
-
importJobs[importJobId].errors.push(errorMsg);
|
|
3467
|
-
importResults.success = false;
|
|
3468
|
-
}
|
|
3469
|
-
}
|
|
3470
|
-
// --- FIN DE LA MODIFICATION PRINCIPALE ---
|
|
3471
|
-
|
|
3472
|
-
}
|
|
3473
|
-
else if (file.type === 'text/csv' || file.name.endsWith('.csv')) {
|
|
3474
|
-
// --- Logique CSV (inchangée, mais maintenant elle est séparée de la logique JSON) ---
|
|
3475
|
-
fileProcessed = true;
|
|
3476
|
-
const modelNameForImport = options.model;
|
|
3477
|
-
if (!modelNameForImport) {
|
|
3478
|
-
importResults.errors.push("Model name is required in the request body for CSV import.");
|
|
3479
|
-
importResults.success = false;
|
|
3480
|
-
} else {
|
|
3481
|
-
try {
|
|
3482
|
-
const modelDef = await getModel(modelNameForImport, user);
|
|
3483
|
-
if (!modelDef) {
|
|
3484
|
-
throw new Error(i18n.t('api.model.notFound', { model: modelNameForImport }));
|
|
3485
|
-
}
|
|
3486
|
-
|
|
3487
|
-
let userDefinedHeadersForMapping = [];
|
|
3488
|
-
if (!hasHeaders && csvHeadersString && typeof csvHeadersString === 'string') {
|
|
3489
|
-
userDefinedHeadersForMapping = csvHeadersString.split(',').map(h => h.trim());
|
|
3490
|
-
}
|
|
3491
|
-
|
|
3492
|
-
const records = await runImportExportWorker('parse-csv', {
|
|
3493
|
-
fileContent: fileContent.toString(),
|
|
3494
|
-
options: { columns: hasHeaders }
|
|
3495
|
-
});
|
|
3496
|
-
|
|
3497
|
-
let datasToImport;
|
|
3498
|
-
if (!hasHeaders) {
|
|
3499
|
-
const effectiveHeadersForMapping = (userDefinedHeadersForMapping.length > 0 && userDefinedHeadersForMapping.some(h => h !== ''))
|
|
3500
|
-
? userDefinedHeadersForMapping
|
|
3501
|
-
: modelDef.fields.map(f => f.name);
|
|
3502
|
-
|
|
3503
|
-
datasToImport = records.map(recordRow => {
|
|
3504
|
-
const obj = {};
|
|
3505
|
-
if (Array.isArray(recordRow)) {
|
|
3506
|
-
recordRow.forEach((value, index) => {
|
|
3507
|
-
const targetModelFieldName = effectiveHeadersForMapping[index];
|
|
3508
|
-
if (targetModelFieldName && targetModelFieldName !== '') {
|
|
3509
|
-
if (modelDef.fields.some(mf => mf.name === targetModelFieldName)) {
|
|
3510
|
-
obj[targetModelFieldName] = value;
|
|
3511
|
-
} else {
|
|
3512
|
-
logger.warn(`CSV Import (!hasHeaders): Specified target field "${targetModelFieldName}" at column ${index + 1} does not exist in model "${modelNameForImport}". Skipping column.`);
|
|
3513
|
-
}
|
|
3514
|
-
}
|
|
3515
|
-
});
|
|
3516
|
-
} else {
|
|
3517
|
-
Object.values(recordRow).forEach((value, index) => {
|
|
3518
|
-
const targetModelFieldName = effectiveHeadersForMapping[index];
|
|
3519
|
-
if (targetModelFieldName && targetModelFieldName !== '') {
|
|
3520
|
-
if (modelDef.fields.some(mf => mf.name === targetModelFieldName)) {
|
|
3521
|
-
obj[targetModelFieldName] = value;
|
|
3522
|
-
} else {
|
|
3523
|
-
logger.warn(`CSV Import (!hasHeaders, object row): Specified target field "${targetModelFieldName}" at column ${index + 1} does not exist in model "${modelNameForImport}". Skipping column.`);
|
|
3524
|
-
}
|
|
3525
|
-
}
|
|
3526
|
-
});
|
|
3527
|
-
}
|
|
3528
|
-
return obj;
|
|
3529
|
-
});
|
|
3530
|
-
} else {
|
|
3531
|
-
datasToImport = records;
|
|
3532
|
-
}
|
|
3533
|
-
const allProcessedData = convertDataTypes(datasToImport, modelDef.fields, 'csv');
|
|
3534
|
-
|
|
3535
|
-
// Logique de découpage en lots pour le CSV
|
|
3536
|
-
if (allProcessedData.length > 0) {
|
|
3537
|
-
importJobs[importJobId].totalRecords = allProcessedData.length;
|
|
3538
|
-
importJobs[importJobId].status = 'processing';
|
|
3539
|
-
|
|
3540
|
-
for (let i = 0; i < allProcessedData.length; i += IMPORT_CHUNK_SIZE) {
|
|
3541
|
-
const chunk = allProcessedData.slice(i, i + IMPORT_CHUNK_SIZE);
|
|
3542
|
-
try {
|
|
3543
|
-
const insertedIdsArray = await pushDataUnsecure(chunk, modelNameForImport, user, {});
|
|
3544
|
-
if (insertedIdsArray && insertedIdsArray.length > 0) {
|
|
3545
|
-
importJobs[importJobId].processedRecords += insertedIdsArray.length;
|
|
3546
|
-
await sendSseToUser(user.username, {
|
|
3547
|
-
type: 'import_progress',
|
|
3548
|
-
job: importJobs[importJobId]
|
|
3549
|
-
});
|
|
3550
|
-
}
|
|
3551
|
-
} catch (chunkError) {
|
|
3552
|
-
const errorMsg = `[Import Job ${importJobId}] Error on CSV chunk: ${chunkError.message}`;
|
|
3553
|
-
logger.error(errorMsg, chunkError.stack);
|
|
3554
|
-
importResults.errors.push(errorMsg);
|
|
3555
|
-
importJobs[importJobId].errors.push(errorMsg);
|
|
3556
|
-
importResults.success = false;
|
|
3557
|
-
}
|
|
3558
|
-
if (i + IMPORT_CHUNK_SIZE < allProcessedData.length) {
|
|
3559
|
-
await delay(IMPORT_CHUNK_DELAY_MS);
|
|
3560
|
-
}
|
|
3561
|
-
}
|
|
3562
|
-
importResults.counts[modelNameForImport] = (importResults.counts[modelNameForImport] || 0) + allProcessedData.length;
|
|
3563
|
-
}
|
|
3564
|
-
|
|
3565
|
-
} catch (modelProcessingError) {
|
|
3566
|
-
logger.error(`[Import CSV] Error processing model ${modelNameForImport}: ${modelProcessingError.message}`);
|
|
3567
|
-
importResults.errors.push(`Model ${modelNameForImport} (CSV): ${modelProcessingError.message}`);
|
|
3568
|
-
importResults.success = false;
|
|
3569
|
-
importJobs[importJobId].errors.push(`Model ${modelNameForImport} (CSV): ${modelProcessingError.message}`);
|
|
3570
|
-
}
|
|
3571
|
-
}
|
|
3572
|
-
}
|
|
3573
|
-
else if (['application/vnd.ms-excel', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'].includes(file.type) || file.name.endsWith('.xls') || file.name.endsWith('.xlsx')) {
|
|
3574
|
-
fileProcessed = true;
|
|
3575
|
-
const modelNameForImport = options.model;
|
|
3576
|
-
if (!modelNameForImport) {
|
|
3577
|
-
importResults.errors.push("Model name is required in the request body for Excel import.");
|
|
3578
|
-
importResults.success = false;
|
|
3579
|
-
} else {
|
|
3580
|
-
try {
|
|
3581
|
-
const modelDef = await getModel(modelNameForImport, user);
|
|
3582
|
-
if (!modelDef) {
|
|
3583
|
-
throw new Error(i18n.t('api.model.notFound', { model: modelNameForImport }));
|
|
3584
|
-
}
|
|
3585
|
-
|
|
3586
|
-
let datasToImport;
|
|
3587
|
-
let excelErrors = [];
|
|
3588
|
-
|
|
3589
|
-
// Cas 2: Pas d'en-têtes. On lit les lignes brutes et on les mappe.
|
|
3590
|
-
const rows = await readXlsxFile(fileContent, { sheet: 1 });
|
|
3591
|
-
|
|
3592
|
-
let userDefinedHeadersForMapping = [];
|
|
3593
|
-
if (csvHeadersString && typeof csvHeadersString === 'string') {
|
|
3594
|
-
userDefinedHeadersForMapping = csvHeadersString.split(',').map(h => h.trim());
|
|
3595
|
-
}
|
|
3596
|
-
|
|
3597
|
-
const effectiveHeadersForMapping = (userDefinedHeadersForMapping.length > 0 && userDefinedHeadersForMapping.some(h => h !== ''))
|
|
3598
|
-
? userDefinedHeadersForMapping
|
|
3599
|
-
: modelDef.fields.map(f => f.name);
|
|
3600
|
-
|
|
3601
|
-
datasToImport = rows.map(recordRow => {
|
|
3602
|
-
const obj = {};
|
|
3603
|
-
if (Array.isArray(recordRow)) {
|
|
3604
|
-
recordRow.forEach((value, index) => {
|
|
3605
|
-
const targetModelFieldName = effectiveHeadersForMapping[index];
|
|
3606
|
-
if (targetModelFieldName && targetModelFieldName !== '') {
|
|
3607
|
-
if (modelDef.fields.some(mf => mf.name === targetModelFieldName)) {
|
|
3608
|
-
obj[targetModelFieldName] = value;
|
|
3609
|
-
} else {
|
|
3610
|
-
logger.warn(`Excel Import (!hasHeaders): Specified target field "${targetModelFieldName}" at column ${index + 1} does not exist in model "${modelNameForImport}". Skipping column.`);
|
|
3611
|
-
}
|
|
3612
|
-
}
|
|
3613
|
-
});
|
|
3614
|
-
}
|
|
3615
|
-
return obj;
|
|
3616
|
-
});
|
|
3617
|
-
|
|
3618
|
-
if (excelErrors.length > 0) {
|
|
3619
|
-
excelErrors.forEach(error => {
|
|
3620
|
-
const errorMsg = `Excel Import Error (Row ${error.row}, Column "${error.column}"): ${error.error}.`;
|
|
3621
|
-
logger.error(`[Import Job ${importJobId}] ${errorMsg}`);
|
|
3622
|
-
importResults.errors.push(errorMsg);
|
|
3623
|
-
importJobs[importJobId].errors.push(errorMsg);
|
|
3624
|
-
});
|
|
3625
|
-
importResults.success = false;
|
|
3626
|
-
}
|
|
3627
|
-
|
|
3628
|
-
if (datasToImport && datasToImport.length > 0) {
|
|
3629
|
-
const allProcessedData = convertDataTypes(datasToImport, modelDef.fields, 'excel');
|
|
3630
|
-
|
|
3631
|
-
importJobs[importJobId].totalRecords = allProcessedData.length;
|
|
3632
|
-
importJobs[importJobId].status = 'processing';
|
|
3633
|
-
|
|
3634
|
-
for (let i = 0; i < allProcessedData.length; i += IMPORT_CHUNK_SIZE) {
|
|
3635
|
-
const chunk = allProcessedData.slice(i, i + IMPORT_CHUNK_SIZE);
|
|
3636
|
-
try {
|
|
3637
|
-
const insertedIdsArray = await pushDataUnsecure(chunk, modelNameForImport, user, {});
|
|
3638
|
-
if (insertedIdsArray && insertedIdsArray.length > 0) {
|
|
3639
|
-
importJobs[importJobId].processedRecords += insertedIdsArray.length;
|
|
3640
|
-
await sendSseToUser(user.username, {
|
|
3641
|
-
type: 'import_progress',
|
|
3642
|
-
job: importJobs[importJobId]
|
|
3643
|
-
});
|
|
3644
|
-
}
|
|
3645
|
-
} catch (chunkError) {
|
|
3646
|
-
const errorMsg = `[Import Job ${importJobId}] Error on Excel chunk: ${chunkError.message}`;
|
|
3647
|
-
logger.error(errorMsg, chunkError.stack);
|
|
3648
|
-
importResults.errors.push(errorMsg);
|
|
3649
|
-
importJobs[importJobId].errors.push(errorMsg);
|
|
3650
|
-
importResults.success = false;
|
|
3651
|
-
}
|
|
3652
|
-
if (i + IMPORT_CHUNK_SIZE < allProcessedData.length) await delay(IMPORT_CHUNK_DELAY_MS);
|
|
3653
|
-
}
|
|
3654
|
-
importResults.counts[modelNameForImport] = (importResults.counts[modelNameForImport] || 0) + allProcessedData.length;
|
|
3655
|
-
}
|
|
3656
|
-
|
|
3657
|
-
} catch (modelProcessingError) {
|
|
3658
|
-
logger.error(`[Import Excel] Error processing model ${modelNameForImport}: ${modelProcessingError.message}`);
|
|
3659
|
-
importResults.errors.push(`Model ${modelNameForImport} (Excel): ${modelProcessingError.message}`);
|
|
3660
|
-
importResults.success = false;
|
|
3661
|
-
importJobs[importJobId].errors.push(`Model ${modelNameForImport} (Excel): ${modelProcessingError.message}`);
|
|
3662
|
-
}
|
|
3663
|
-
}
|
|
3664
|
-
}
|
|
3665
|
-
else {
|
|
3666
|
-
importResults.errors.push("Unsupported file type. Please upload a JSON or CSV file.");
|
|
3667
|
-
importResults.success = false;
|
|
3668
|
-
importJobs[importJobId].errors.push("Unsupported file type. Please upload a JSON or CSV file.");
|
|
3669
|
-
}
|
|
3670
|
-
|
|
3671
|
-
} catch (e) {
|
|
3672
|
-
logger.error("Import Error (Global):", e);
|
|
3673
|
-
importResults.success = false;
|
|
3674
|
-
importResults.errors.push(e.message || "An unexpected error occurred during import.");
|
|
3675
|
-
if (importJobs[importJobId]) {
|
|
3676
|
-
importJobs[importJobId].errors.push(e.message || "An unexpected error occurred during import.");
|
|
3677
|
-
}
|
|
3678
|
-
} finally {
|
|
3679
|
-
if (importJobs[importJobId]) {
|
|
3680
|
-
if (importResults.errors.length > 0) {
|
|
3681
|
-
importJobs[importJobId].status = 'failed';
|
|
3682
|
-
} else {
|
|
3683
|
-
importJobs[importJobId].status = 'completed';
|
|
3684
|
-
}
|
|
3685
|
-
await sendSseToUser(user.username, {
|
|
3686
|
-
type: 'import_progress',
|
|
3687
|
-
job: importJobs[importJobId]
|
|
3688
|
-
});
|
|
3689
|
-
}
|
|
3690
|
-
if (file && file.path && fs.existsSync(file.path)) {
|
|
3691
|
-
fs.unlinkSync(file.path);
|
|
3692
|
-
}
|
|
3693
|
-
}
|
|
3694
|
-
})().catch(error => {
|
|
3695
|
-
logger.error(`Unhandled error in background import job ${importJobId}:`, error);
|
|
3696
|
-
if (importJobs[importJobId]) {
|
|
3697
|
-
importJobs[importJobId].status = 'failed';
|
|
3698
|
-
importJobs[importJobId].errors.push(error.message || "An unhandled error occurred in background process.");
|
|
3699
|
-
sendSseToUser(user.username, {
|
|
3700
|
-
type: 'import_progress',
|
|
3701
|
-
job: importJobs[importJobId]
|
|
3702
|
-
});
|
|
3703
|
-
}
|
|
3704
|
-
});
|
|
3705
|
-
return ({success: true, message: "Import initiated. Check progress via SSE.", job: importJob});
|
|
3706
|
-
}
|
|
3707
|
-
|
|
3708
|
-
export const exportData= async (options, user) =>{
|
|
3709
|
-
// Extract parameters from request body and query
|
|
3710
|
-
const { models, ids, filter = {}, depth, lang } = options;
|
|
3711
|
-
const userId = getUserId(user);
|
|
3712
|
-
|
|
3713
|
-
const effectiveMaxDepth = maxExportCount ?? maxFilterDepth; // Use defined constant or fallback
|
|
3714
|
-
i18n.changeLanguage(lang);
|
|
3715
|
-
|
|
3716
|
-
// --- Input Validation ---
|
|
3717
|
-
if (!Array.isArray(models) || models.length === 0) {
|
|
3718
|
-
return { success: false, error: i18n.t('api.export.error.noModels', 'Models array is required.') };
|
|
3719
|
-
}
|
|
3720
|
-
const parsedDepth = parseInt(depth, 10);
|
|
3721
|
-
if (isNaN(parsedDepth) || parsedDepth < 0 || parsedDepth > effectiveMaxDepth) {
|
|
3722
|
-
return { success: false, error: i18n.t('api.export.error.invalidDepth', `Invalid depth parameter. Must be between 0 and ${effectiveMaxDepth}.`, { maxDepth: effectiveMaxDepth }) };
|
|
3723
|
-
}
|
|
3724
|
-
if (ids && !Array.isArray(ids)) {
|
|
3725
|
-
return { success: false, error: i18n.t('api.export.error.invalidIdsType', 'ids parameter must be an array if provided.') };
|
|
3726
|
-
}
|
|
3727
|
-
if (ids && !ids.every(id => typeof id === 'string' || isObjectId(id))) { // Allow string or ObjectId format
|
|
3728
|
-
return { success: false, error: i18n.t('api.export.error.invalidIdsContent', 'ids parameter must contain valid identifiers (strings or ObjectIds).') };
|
|
3729
|
-
}
|
|
3730
|
-
|
|
3731
|
-
const exportResults = {};
|
|
3732
|
-
let totalDocsFetched = 0;
|
|
3733
|
-
const errors = [];
|
|
3734
|
-
|
|
3735
|
-
let modelsToExport = [];
|
|
3736
|
-
|
|
3737
|
-
// --- Permissions & Data Fetching Loop ---
|
|
3738
|
-
for (const modelName of models) {
|
|
3739
|
-
if (totalDocsFetched >= maxExportCount) {
|
|
3740
|
-
console.warn(`Export limit of ${maxExportCount} documents reached before processing model ${modelName}.`);
|
|
3741
|
-
errors.push(i18n.t('api.export.error.limitReached', 'Export document limit reached.', { limit: maxExportCount }));
|
|
3742
|
-
break; // Stop fetching more models if limit is hit early
|
|
3743
|
-
}
|
|
3744
|
-
|
|
3745
|
-
try {
|
|
3746
|
-
// Check permission to search/export data for this model
|
|
3747
|
-
// Using API_SEARCH_DATA as a proxy, adjust if a specific export permission exists
|
|
3748
|
-
// Assuming checkPermission is adapted or hasPermission is used directly
|
|
3749
|
-
// Note: Your original code used checkPermission, but data.js has hasPermission. Adjust as needed.
|
|
3750
|
-
// Example using hasPermission:
|
|
3751
|
-
// if (user.username !== 'demo' && isLocalUser(user) && !await hasPermission(["API_ADMIN", "API_SEARCH_DATA", "API_SEARCH_DATA_"+modelName], user)) {
|
|
3752
|
-
// Example using checkPermission (if it exists and works similarly):
|
|
3753
|
-
if (isLocalUser(user) && !(isDemoUser(user) && Config.Get("useDemoAccounts")) && !(await hasPermission('API_EXPORT_DATA', user))) { // Adapt this line based on your actual permission function
|
|
3754
|
-
console.warn(`User ${userId} lacks permission to search/export model ${modelName}`);
|
|
3755
|
-
errors.push(i18n.t('api.permission.searchData', 'Cannot search data from the API') + ` (${modelName})`);
|
|
3756
|
-
continue; // Skip this model
|
|
3757
|
-
}
|
|
3758
|
-
|
|
3759
|
-
// Fetch model definition securely (needed by searchData internally anyway)
|
|
3760
|
-
// getModel might throw if not found, handle this
|
|
3761
|
-
try {
|
|
3762
|
-
const mod = await getModel(modelName, user);
|
|
3763
|
-
modelsToExport.push(mod);
|
|
3764
|
-
} catch (modelError) {
|
|
3765
|
-
console.warn(`Model ${modelName} not found or not accessible for user ${userId}: ${modelError.message}`);
|
|
3766
|
-
errors.push(i18n.t('api.model.notFound', 'Model {{model}} not found.', { model: modelName }));
|
|
3767
|
-
continue; // Skip this model
|
|
3768
|
-
}
|
|
3769
|
-
|
|
3770
|
-
|
|
3771
|
-
|
|
3772
|
-
// Construct the query filter for the current model
|
|
3773
|
-
let modelSpecificFilter = { ... (filter[modelName] || {}) }; // Use model-specific filter from request if provided
|
|
3774
|
-
// _user filter is handled internally by searchData based on the user object passed
|
|
3775
|
-
|
|
3776
|
-
if( ids && ids.length > 0)
|
|
3777
|
-
modelSpecificFilter = { $in : ['$_id', ids]};
|
|
3778
|
-
|
|
3779
|
-
// Calculate remaining limit for this model
|
|
3780
|
-
const remainingLimit = maxExportCount - totalDocsFetched;
|
|
3781
|
-
|
|
3782
|
-
// --- Fetch Data using searchData ---
|
|
3783
|
-
const searchParams = {
|
|
3784
|
-
model: modelName,
|
|
3785
|
-
filter: modelSpecificFilter,
|
|
3786
|
-
depth: parsedDepth,
|
|
3787
|
-
limit: remainingLimit
|
|
3788
|
-
};
|
|
3789
|
-
|
|
3790
|
-
const { data: resultData, count } = await searchData(searchParams, user);
|
|
3791
|
-
|
|
3792
|
-
if (resultData && resultData.length > 0) {
|
|
3793
|
-
exportResults[modelName] = resultData;
|
|
3794
|
-
totalDocsFetched += resultData.length;
|
|
3795
|
-
logger.debug(`Fetched ${resultData.length} documents for model ${modelName}. Total: ${totalDocsFetched}`);
|
|
3796
|
-
} else {
|
|
3797
|
-
logger.debug(`No documents found for model ${modelName} with the given criteria.`);
|
|
3798
|
-
}
|
|
3799
|
-
|
|
3800
|
-
} catch (modelError) {
|
|
3801
|
-
// Catch errors specific to processing this model (e.g., from searchData)
|
|
3802
|
-
console.error(`Error exporting model ${modelName}:`, modelError);
|
|
3803
|
-
errors.push(i18n.t('api.export.error.modelError', 'Error exporting model {{model}}.', { model: modelName }) + ` (${modelError.message})`);
|
|
3804
|
-
}
|
|
3805
|
-
} // End of loop through models
|
|
3806
|
-
|
|
3807
|
-
// --- Prepare and Send Response ---
|
|
3808
|
-
if (Object.keys(exportResults).length === 0) {
|
|
3809
|
-
const finalError = errors.length > 0 ? errors.join('; ') : i18n.t('api.data.noExportData', 'No data found for the specified criteria or permissions denied.');
|
|
3810
|
-
// Use 404 if no data found/accessible, 400 if only errors occurred but no data attempt was possible
|
|
3811
|
-
const statusCode = errors.length > 0 && totalDocsFetched === 0 ? 400 : 404;
|
|
3812
|
-
return { success: false, error: finalError };
|
|
3813
|
-
}
|
|
3814
|
-
|
|
3815
|
-
// Include errors in the response if any occurred but some data was fetched
|
|
3816
|
-
if (errors.length > 0) {
|
|
3817
|
-
exportResults._exportErrors = errors;
|
|
3818
|
-
}
|
|
3819
|
-
|
|
3820
|
-
const res = { success: true, data: exportResults, models: modelsToExport };
|
|
3821
|
-
const plugin = await Event.Trigger("OnDataExported", "event", "system", engine, exportResults, modelsToExport);
|
|
3822
|
-
await Event.Trigger("OnDataExported", "event", "user", plugin?.exportResults || exportResults, plugin?.modelsToExport || modelsToExport);
|
|
3823
|
-
return plugin || res;
|
|
3824
|
-
}
|
|
3825
|
-
|
|
3826
|
-
function handleCalculationExpression(calcExpression, fi, modelElement, calculationName) {
|
|
3827
|
-
// Check if the calculation expression involves an operator
|
|
3828
|
-
if (typeof calcExpression === 'object' && calcExpression !== null && Object.keys(calcExpression).length === 1) {
|
|
3829
|
-
const operator = Object.keys(calcExpression)[0];
|
|
3830
|
-
const operands = calcExpression[operator];
|
|
3831
|
-
|
|
3832
|
-
// Validation of isValidAggregationOperator
|
|
3833
|
-
if (!isValidAggregationOperator(operator)) {
|
|
3834
|
-
logger.warn(`Invalid aggregation operator '${operator}' in calculation. Skipping.`);
|
|
3835
|
-
return null;
|
|
3836
|
-
}
|
|
3837
|
-
|
|
3838
|
-
// Check Operand Count and Apply $ifNull handling
|
|
3839
|
-
if (Array.isArray(operands)) {
|
|
3840
|
-
const handledOperands = operands.map(operand => handleOperand(operand, fi, modelElement, calculationName));
|
|
3841
|
-
if (handledOperands.some(op => op === null)) { // Skip if any operand is invalid
|
|
3842
|
-
return null;
|
|
3843
|
-
}
|
|
3844
|
-
return { [operator]: handledOperands };
|
|
3845
|
-
} else {
|
|
3846
|
-
logger.warn(`Invalid operands for operator '${operator}'. Expected an array. Skipping.`);
|
|
3847
|
-
return null;
|
|
3848
|
-
}
|
|
3849
|
-
} else if (typeof calcExpression === 'string' && calcExpression.startsWith('$')) {
|
|
3850
|
-
// This is a field reference
|
|
3851
|
-
return handleOperand(calcExpression, fi, modelElement, calculationName);
|
|
3852
|
-
} else {
|
|
3853
|
-
// This is a constant value, return as is.
|
|
3854
|
-
return calcExpression;
|
|
3855
|
-
}
|
|
3856
|
-
}
|
|
3857
|
-
|
|
3858
|
-
function handleOperand(operand, fi, modelElement, calculationName) {
|
|
3859
|
-
if (typeof operand === 'object' && operand !== null && Object.keys(operand).length === 1) {
|
|
3860
|
-
// Nested Calculation: recursively handle
|
|
3861
|
-
return handleCalculationExpression(operand, fi, modelElement, calculationName);
|
|
3862
|
-
} else if (typeof operand === 'string' && operand.startsWith('$')) {
|
|
3863
|
-
// Field Reference: check field existence
|
|
3864
|
-
const fieldName = operand.slice(1);
|
|
3865
|
-
if (!isValidFieldReference(fieldName, modelElement)) {
|
|
3866
|
-
logger.warn(`Invalid field reference '${fieldName}' in calculation. Skipping.`);
|
|
3867
|
-
return null;
|
|
3868
|
-
}
|
|
3869
|
-
return operand;
|
|
3870
|
-
} else {
|
|
3871
|
-
// Constant Value
|
|
3872
|
-
return operand;
|
|
3873
|
-
}
|
|
3874
|
-
}
|
|
3875
|
-
|
|
3876
|
-
function isValidFieldReference(fieldName, modelElement) {
|
|
3877
|
-
// Check if the field exists in the model
|
|
3878
|
-
return modelElement.fields.some(field => field.name === fieldName);
|
|
3879
|
-
}
|
|
3880
|
-
function isValidAggregationOperator(operator) {
|
|
3881
|
-
const arithmeticOperators = [
|
|
3882
|
-
'$add', '$subtract', '$multiply', '$divide', '$mod', '$pow',
|
|
3883
|
-
'$abs', '$ceil', '$floor', '$round', '$trunc', '$exp', '$log', '$log10'
|
|
3884
|
-
];
|
|
3885
|
-
const comparisonOperators = [
|
|
3886
|
-
'$eq', '$gt', '$gte', '$lt', '$lte', '$ne'
|
|
3887
|
-
// ... (others like $cmp, $strcasecmp, etc.)
|
|
3888
|
-
];
|
|
3889
|
-
const stringOperators = [
|
|
3890
|
-
'$concat', '$strLenCP', '$substrCP', '$toLower', '$toUpper'
|
|
3891
|
-
// ... (others)
|
|
3892
|
-
];
|
|
3893
|
-
const conditionalOperators = ['$cond', '$ifNull'];
|
|
3894
|
-
// Add more categories and operators as needed
|
|
3895
|
-
|
|
3896
|
-
return [...arithmeticOperators, ...comparisonOperators, ...stringOperators, ...conditionalOperators].includes(operator);
|
|
3897
|
-
}
|
|
3898
|
-
|
|
3899
|
-
/**
|
|
3900
|
-
* Gère les traductions spécifiques à l'utilisateur et traite les données de manière récursive
|
|
3901
|
-
* pour l'anonymisation et la résolution des relations.
|
|
3902
|
-
*
|
|
3903
|
-
* Cette fonction est le point d'entrée principal. Elle charge temporairement les traductions
|
|
3904
|
-
* d'un utilisateur, traite les données, puis décharge les traductions pour garantir
|
|
3905
|
-
* qu'une requête n'affecte pas les autres.
|
|
3906
|
-
*
|
|
3907
|
-
* @param {object} model - La définition du modèle pour les données actuelles.
|
|
3908
|
-
* @param {object|Array<object>|null} data - Les données à traiter (un objet, un tableau ou null).
|
|
3909
|
-
* @param {object} user - L'objet utilisateur effectuant la requête.
|
|
3910
|
-
* @param {boolean} [isRecursiveCall=false] - Un drapeau interne pour éviter de recharger les traductions lors des appels récursifs.
|
|
3911
|
-
* @returns {Promise<object|Array<object>|null>} - Les données traitées, dans le même format que l'entrée.
|
|
3912
|
-
*/
|
|
3913
|
-
const handleFields = async (model, data, user, isRecursiveCall = false) => {
|
|
3914
|
-
// Détermine si l'entrée était un tableau pour retourner le même format.
|
|
3915
|
-
const wasArray = Array.isArray(data);
|
|
3916
|
-
// Normalise les données en tableau pour un traitement unifié. Gère le cas où data est null/undefined.
|
|
3917
|
-
const dataArray = wasArray ? data : (data ? [data] : []);
|
|
3918
|
-
|
|
3919
|
-
if (dataArray.length === 0) {
|
|
3920
|
-
return wasArray ? [] : null;
|
|
3921
|
-
}
|
|
3922
|
-
|
|
3923
|
-
// Fonction interne pour traiter les données. Appelée après la gestion des traductions.
|
|
3924
|
-
const _processItems = async (items) => {
|
|
3925
|
-
const canRead = !isLocalUser(user) || await hasPermission(["API_ADMIN", "API_DEANONYMIZED", "API_DEANONYMIZED_" + model.name], user);
|
|
3926
|
-
|
|
3927
|
-
for (const item of items) {
|
|
3928
|
-
if (!item) continue;
|
|
3929
|
-
|
|
3930
|
-
if( item['_id']){
|
|
3931
|
-
item['_id'] = item['_id'].toString();
|
|
3932
|
-
}
|
|
3933
|
-
|
|
3934
|
-
for (const field of model.fields) {
|
|
3935
|
-
const fieldName = field.name;
|
|
3936
|
-
const fieldValue = item[fieldName];
|
|
3937
|
-
|
|
3938
|
-
if (fieldValue === undefined) continue;
|
|
3939
|
-
|
|
3940
|
-
// 1. Anonymisation des champs si nécessaire
|
|
3941
|
-
if (field.anonymized && !canRead && dataTypes[field.type]?.anonymize) {
|
|
3942
|
-
item[fieldName] = dataTypes[field.type].anonymize(fieldValue, field, getObjectHash({ id: item._id }));
|
|
3943
|
-
}
|
|
3944
|
-
|
|
3945
|
-
if( field.type === 'string_t'){
|
|
3946
|
-
item[fieldName] = { key: fieldValue, value: i18n.t(fieldValue, fieldValue) };
|
|
3947
|
-
}
|
|
3948
|
-
|
|
3949
|
-
// 2. Résolution récursive des relations
|
|
3950
|
-
|
|
3951
|
-
if (field.type === 'relation' && fieldValue) {
|
|
3952
|
-
try {
|
|
3953
|
-
const relatedModel = await getModel(field.relation, user);
|
|
3954
|
-
// Appel récursif à la fonction principale, en signalant que ce n'est pas l'appel initial.
|
|
3955
|
-
item[fieldName] = await handleFields(relatedModel, fieldValue, user, true);
|
|
3956
|
-
if (!field.multiple && Array.isArray(item[fieldName]) && item[fieldName].length <= 1) {
|
|
3957
|
-
item[fieldName] = item[fieldName][0] || null;
|
|
3958
|
-
}
|
|
3959
|
-
} catch (e) {
|
|
3960
|
-
logger.warn(`Impossible de traiter la relation pour le champ '${fieldName}' du modèle '${model.name}'. Erreur: ${e.message}`);
|
|
3961
|
-
// En cas d'erreur (ex: modèle de relation introuvable), on conserve la valeur originale (probablement un ID).
|
|
3962
|
-
}
|
|
3963
|
-
}
|
|
3964
|
-
}
|
|
3965
|
-
}
|
|
3966
|
-
return items;
|
|
3967
|
-
};
|
|
3968
|
-
|
|
3969
|
-
// Si c'est l'appel initial (non récursif) et qu'un utilisateur est fourni, on gère les traductions.
|
|
3970
|
-
if (!isRecursiveCall && user?.username) {
|
|
3971
|
-
const lang = user.lang || 'en'; // Utilise la langue définie par le middleware, sinon 'en'.
|
|
3972
|
-
let originalTranslations = null;
|
|
3973
|
-
let userTranslationsLoaded = false;
|
|
3974
|
-
|
|
3975
|
-
|
|
3976
|
-
try {
|
|
3977
|
-
const coll = await getCollectionForUser(user);
|
|
3978
|
-
// 1. Récupérer l'ID du document de langue de l'utilisateur pour la langue actuelle.
|
|
3979
|
-
const userLangDoc = await coll.findOne({
|
|
3980
|
-
_model: 'lang',
|
|
3981
|
-
code: lang,
|
|
3982
|
-
_user: user.username
|
|
3983
|
-
});
|
|
3984
|
-
|
|
3985
|
-
if (userLangDoc) {
|
|
3986
|
-
// 2. Récupérer les traductions de l'utilisateur pour cette langue.
|
|
3987
|
-
const userTranslationsArray = await coll.find({
|
|
3988
|
-
_model: 'translation',
|
|
3989
|
-
_user: user.username,
|
|
3990
|
-
lang: userLangDoc._id.toString()
|
|
3991
|
-
}).toArray();
|
|
3992
|
-
|
|
3993
|
-
if (userTranslationsArray.length > 0) {
|
|
3994
|
-
// 3. Préparer le "bundle" de ressources pour i18n.
|
|
3995
|
-
const newResourceBundle = userTranslationsArray.reduce((acc, tr) => {
|
|
3996
|
-
if (tr.key && tr.value) {
|
|
3997
|
-
acc[tr.key] = tr.value;
|
|
3998
|
-
}
|
|
3999
|
-
return acc;
|
|
4000
|
-
}, {});
|
|
4001
|
-
|
|
4002
|
-
|
|
4003
|
-
// 4. Charger temporairement les traductions de l'utilisateur.
|
|
4004
|
-
if (Object.keys(newResourceBundle).length > 0) {
|
|
4005
|
-
// Sauvegarder les traductions originales si elles existent
|
|
4006
|
-
if (i18n.store.data[lang] && i18n.store.data[lang].translation) {
|
|
4007
|
-
originalTranslations = {...i18n.store.data[lang].translation};
|
|
4008
|
-
}
|
|
4009
|
-
// Ajoute/remplace les clés de traduction pour la langue et le namespace courants.
|
|
4010
|
-
i18n.addResourceBundle(lang, 'translation', newResourceBundle, true, true);
|
|
4011
|
-
userTranslationsLoaded = true;
|
|
4012
|
-
logger.debug(`Chargement de ${userTranslationsArray.length} traductions personnalisées pour l'utilisateur '${user.username}' en '${lang}'.`);
|
|
4013
|
-
}
|
|
4014
|
-
}
|
|
4015
|
-
}
|
|
4016
|
-
|
|
4017
|
-
// 5. Traiter les données avec les traductions (personnalisées ou par défaut).
|
|
4018
|
-
const processedData = await _processItems(dataArray);
|
|
4019
|
-
return wasArray ? processedData : processedData[0];
|
|
4020
|
-
} finally {
|
|
4021
|
-
// 6. Nettoyage : décharger les traductions de l'utilisateur et restaurer les originales.
|
|
4022
|
-
// Ce bloc s'exécute toujours, même en cas d'erreur, garantissant l'isolation des requêtes.
|
|
4023
|
-
if (userTranslationsLoaded) {
|
|
4024
|
-
// Supprime le namespace temporaire.
|
|
4025
|
-
i18n.removeResourceBundle(lang, 'translation');
|
|
4026
|
-
logger.debug(`Déchargement des traductions personnalisées pour l'utilisateur '${user.username}' en '${lang}'.`);
|
|
4027
|
-
|
|
4028
|
-
// Restaure les traductions originales si elles avaient été sauvegardées.
|
|
4029
|
-
if (originalTranslations) {
|
|
4030
|
-
i18n.addResourceBundle(lang, 'translation', originalTranslations, true, true);
|
|
4031
|
-
logger.debug(`Restauration des traductions originales pour la langue '${lang}'.`);
|
|
4032
|
-
}
|
|
4033
|
-
}
|
|
4034
|
-
}
|
|
4035
|
-
} else {
|
|
4036
|
-
// C'est un appel récursif ou il n'y a pas d'utilisateur, on traite donc directement les données.
|
|
4037
|
-
const processedData = await _processItems(dataArray);
|
|
4038
|
-
return wasArray ? processedData : processedData[0];
|
|
4039
|
-
}
|
|
4040
|
-
};
|
|
4041
|
-
|
|
4042
|
-
let restoreRequests = {};
|
|
4043
|
-
export const validateRestoreRequest = (username, token) => {
|
|
4044
|
-
const request = restoreRequests[username];
|
|
4045
|
-
if (!request) {
|
|
4046
|
-
return { error: 'Invalid username.' };
|
|
4047
|
-
}
|
|
4048
|
-
|
|
4049
|
-
if (request.token !== token) {
|
|
4050
|
-
return { error: 'Invalid token.' };
|
|
4051
|
-
}
|
|
4052
|
-
|
|
4053
|
-
if (request.expiresAt < new Date()) {
|
|
4054
|
-
delete restoreRequests[username];
|
|
4055
|
-
return { error: 'Token has expired.' };
|
|
4056
|
-
}
|
|
4057
|
-
|
|
4058
|
-
delete restoreRequests[username]; // Remove the request after validation
|
|
4059
|
-
return { success: true };
|
|
4060
|
-
};
|
|
4061
|
-
|
|
4062
|
-
|
|
4063
|
-
export const loadFromDump = async (user, options = {}) => {
|
|
4064
|
-
const { modelsOnly = false } = options;
|
|
4065
|
-
const action = modelsOnly ? 'restore-models' : 'full-restore';
|
|
4066
|
-
logger.info(`[${action}] Starting for user: ${user.username}`);
|
|
4067
|
-
|
|
4068
|
-
const encryptedKey = readKeyFromFile(user);
|
|
4069
|
-
if (!encryptedKey) {
|
|
4070
|
-
throw new Error("No encryption key found for this user. Cannot restore.");
|
|
4071
|
-
}
|
|
4072
|
-
|
|
4073
|
-
const userId = getObjectHash({ user: user.username });
|
|
4074
|
-
const backupDir = getBackupDir();
|
|
4075
|
-
let backupFilePath = ''; // Will hold the path to the archive to be restored
|
|
4076
|
-
let isTempFile = false; // Flag to know if we need to delete the file later
|
|
4077
|
-
|
|
4078
|
-
const tmpRestoreDir = path.join(backupDir, `tmp_restore_${userId}_${Date.now()}`);
|
|
4079
|
-
|
|
4080
|
-
try {
|
|
4081
|
-
const s3Config = await getUserS3Config(user);
|
|
4082
|
-
// --- NEW LOGIC: Check for S3 config first ---
|
|
4083
|
-
if (s3Config && s3Config.bucketName && s3Config.accessKeyId && s3Config.secretAccessKey) {
|
|
4084
|
-
logger.info(`[${action}] S3 config found for user. Searching for backups in bucket: ${s3Config.bucketName}`);
|
|
4085
|
-
|
|
4086
|
-
const s3Backups = await listS3Backups(s3Config);
|
|
4087
|
-
const userBackups = s3Backups
|
|
4088
|
-
.filter(f => f.filename.startsWith(`backup_${userId}_`))
|
|
4089
|
-
.sort((a, b) => b.timestamp - a.timestamp);
|
|
4090
|
-
|
|
4091
|
-
if (userBackups.length === 0) {
|
|
4092
|
-
throw new Error(`No S3 backups found for user ${user.username} in bucket ${s3Config.bucketName}.`);
|
|
4093
|
-
}
|
|
4094
|
-
|
|
4095
|
-
const latestBackup = userBackups[0];
|
|
4096
|
-
logger.info(`[${action}] Found latest S3 backup: ${latestBackup.key}. Downloading...`);
|
|
4097
|
-
|
|
4098
|
-
// Download the file to a temporary location
|
|
4099
|
-
backupFilePath = path.join(backupDir, latestBackup.filename);
|
|
4100
|
-
isTempFile = true;
|
|
4101
|
-
await downloadFromS3(s3Config, latestBackup.key, backupFilePath);
|
|
4102
|
-
logger.info(`[${action}] S3 backup downloaded to ${backupFilePath}.`);
|
|
4103
|
-
|
|
4104
|
-
} else {
|
|
4105
|
-
// --- FALLBACK LOGIC: Look for local backups ---
|
|
4106
|
-
logger.info(`[${action}] No S3 config. Searching for local backups.`);
|
|
4107
|
-
const backupFilenameRegex = new RegExp(`^backup_${userId}_(\\d+)\\.tar\\.gz$`);
|
|
4108
|
-
const backupFiles = fs.readdirSync(backupDir).filter(filename => backupFilenameRegex.test(filename));
|
|
4109
|
-
if (backupFiles.length === 0) {
|
|
4110
|
-
throw new Error(`No local backup files found for user ${user.username}.`);
|
|
4111
|
-
}
|
|
4112
|
-
const latestBackupFile = backupFiles.sort((a, b) => parseInt(b.match(backupFilenameRegex)[1], 10) - parseInt(a.match(backupFilenameRegex)[1], 10))[0];
|
|
4113
|
-
backupFilePath = path.join(backupDir, latestBackupFile);
|
|
4114
|
-
}
|
|
4115
|
-
|
|
4116
|
-
// --- The rest of the logic remains the same, operating on backupFilePath ---
|
|
4117
|
-
|
|
4118
|
-
await runCryptoWorkerTask('decrypt', { filePath: backupFilePath, password: encryptedKey });
|
|
4119
|
-
|
|
4120
|
-
if (!fs.existsSync(tmpRestoreDir)) {
|
|
4121
|
-
fs.mkdirSync(tmpRestoreDir, { recursive: true });
|
|
4122
|
-
}
|
|
4123
|
-
await tar.extract({ file: backupFilePath, gzip: true, C: tmpRestoreDir, sync: true });
|
|
4124
|
-
|
|
4125
|
-
// ... (Cleaning logic: deleteMany, removeFile, cancelAlerts) ...
|
|
4126
|
-
const datasCollection = getCollection("datas");
|
|
4127
|
-
if (modelsOnly) {
|
|
4128
|
-
await modelsCollection.deleteMany({ _user: user.username });
|
|
4129
|
-
} else {
|
|
4130
|
-
await datasCollection.deleteMany({ _user: user.username });
|
|
4131
|
-
await modelsCollection.deleteMany({ _user: user.username });
|
|
4132
|
-
|
|
4133
|
-
const filesCollection = getCollection("files");
|
|
4134
|
-
const userFiles = await filesCollection.find({ user: user.username, _model: "privateFile" }).toArray();
|
|
4135
|
-
for (const file of userFiles) {
|
|
4136
|
-
await removeFile(file.guid, user).catch(e => logger.error(e.message));
|
|
4137
|
-
}
|
|
4138
|
-
await cancelAlerts(user);
|
|
4139
|
-
logger.info(`[${action}] Cleaned existing data, models, files, and alerts for user ${user.username}.`);
|
|
4140
|
-
}
|
|
4141
|
-
|
|
4142
|
-
// --- EXÉCUTION DE MONGORESTORE ---
|
|
4143
|
-
const restoreSourceDir = path.join(tmpRestoreDir, dbName);
|
|
4144
|
-
if (!fs.existsSync(restoreSourceDir)) {
|
|
4145
|
-
throw new Error(`Restore source directory (${restoreSourceDir}) not found.`);
|
|
4146
|
-
}
|
|
4147
|
-
|
|
4148
|
-
let command;
|
|
4149
|
-
const args = [
|
|
4150
|
-
'--uri', dbUrl,
|
|
4151
|
-
'--db', dbName
|
|
4152
|
-
];
|
|
4153
|
-
|
|
4154
|
-
if (modelsOnly) {
|
|
4155
|
-
args.push('--nsInclude', `${dbName}.models`);
|
|
4156
|
-
} else {
|
|
4157
|
-
// mongorestore accepte plusieurs fois l'option --nsInclude
|
|
4158
|
-
args.push('--nsInclude', `${dbName}.datas`);
|
|
4159
|
-
args.push('--nsInclude', `${dbName}.models`);
|
|
4160
|
-
}
|
|
4161
|
-
// Le répertoire source est le dernier argument
|
|
4162
|
-
args.push(restoreSourceDir);
|
|
4163
|
-
|
|
4164
|
-
|
|
4165
|
-
logger.info(`[${action}] Executing restore command: ${command}`);
|
|
4166
|
-
await execFileAsync('mongorestore', args);
|
|
4167
|
-
|
|
4168
|
-
// ... (Post-restore tasks) ...
|
|
4169
|
-
await scheduleAlerts();
|
|
4170
|
-
await scheduleWorkflowTriggers();
|
|
4171
|
-
modelsCache.flushAll();
|
|
4172
|
-
|
|
4173
|
-
logger.info(`[${action}] Restore successful for user ${user.username}.`);
|
|
4174
|
-
await Event.Trigger("OnDataRestored", "event", "system");
|
|
4175
|
-
|
|
4176
|
-
} finally {
|
|
4177
|
-
// --- GUARANTEED CLEANUP ---
|
|
4178
|
-
if (fs.existsSync(tmpRestoreDir)) {
|
|
4179
|
-
await fs.promises.rm(tmpRestoreDir, { recursive: true, force: true });
|
|
4180
|
-
}
|
|
4181
|
-
|
|
4182
|
-
// Re-encrypt the original file if it's not a temporary one
|
|
4183
|
-
if (fs.existsSync(backupFilePath) && !isTempFile) {
|
|
4184
|
-
await runCryptoWorkerTask('encrypt', { filePath: backupFilePath, password: encryptedKey });
|
|
4185
|
-
}
|
|
4186
|
-
|
|
4187
|
-
// If we downloaded a temp file from S3, delete it
|
|
4188
|
-
if (fs.existsSync(backupFilePath) && isTempFile) {
|
|
4189
|
-
fs.unlinkSync(backupFilePath);
|
|
4190
|
-
logger.info(`[${action}] Deleted temporary downloaded backup file: ${backupFilePath}`);
|
|
4191
|
-
}
|
|
4192
|
-
}
|
|
4193
|
-
};
|
|
4194
|
-
|
|
4195
|
-
// Fonction pour générer une clé aléatoire et la stocker dans un fichier
|
|
4196
|
-
const generateAndStoreKey = (user) => {
|
|
4197
|
-
const backupDir = getBackupDir();
|
|
4198
|
-
const keyFile = path.join(backupDir, getObjectHash({id:getUserId(user)})+'_encryption.key');
|
|
4199
|
-
const key = crypto.randomBytes(16).toString('hex');
|
|
4200
|
-
fs.writeFileSync(keyFile, key, { mode: 0o600 }); // Permissions strictes
|
|
4201
|
-
return key;
|
|
4202
|
-
};
|
|
4203
|
-
|
|
4204
|
-
// Fonction pour lire la clé depuis le fichier
|
|
4205
|
-
const readKeyFromFile = (user) => {
|
|
4206
|
-
const backupDir = getBackupDir();
|
|
4207
|
-
const keyFile = path.join(backupDir, getObjectHash({id:getUserId(user)})+'_encryption.key');
|
|
4208
|
-
if (fs.existsSync(keyFile)) {
|
|
4209
|
-
return fs.readFileSync(keyFile, 'utf8');
|
|
4210
|
-
}
|
|
4211
|
-
return null;
|
|
4212
|
-
};
|
|
4213
|
-
|
|
4214
|
-
export const dumpUserData = async (user) => {
|
|
4215
|
-
const s3Config = await getUserS3Config(user);
|
|
4216
|
-
const backupDir = getBackupDir();
|
|
4217
|
-
const userId = getObjectHash({ user: user.username });
|
|
4218
|
-
const backupFilename = `backup_${userId}`;
|
|
4219
|
-
const timestamp = Date.now();
|
|
4220
|
-
|
|
4221
|
-
// Déclarer les chemins ici pour qu'ils soient accessibles dans tout le scope de la fonction
|
|
4222
|
-
const localTempDumpDir = path.join(backupDir, `${backupFilename}_${timestamp}_temp`);
|
|
4223
|
-
const finalArchiveName = `${backupFilename}_${timestamp}.tar.gz`;
|
|
4224
|
-
const localArchivePath = path.join(backupDir, finalArchiveName);
|
|
4225
|
-
|
|
4226
|
-
let encryptedKey = readKeyFromFile(user);
|
|
4227
|
-
if (!encryptedKey) {
|
|
4228
|
-
encryptedKey = generateAndStoreKey(user);
|
|
4229
|
-
}
|
|
4230
|
-
|
|
4231
|
-
try {
|
|
4232
|
-
const backupFrequency = await engine.userProvider.getBackupFrequency(user);
|
|
4233
|
-
logger.info(`Fréquence de sauvegarde : ${backupFrequency}.`);
|
|
4234
|
-
|
|
4235
|
-
const collections = await MongoDatabase.listCollections().toArray();
|
|
4236
|
-
for (const collection of collections) {
|
|
4237
|
-
const collsToBackup = [await getUserCollectionName(user), 'models'];
|
|
4238
|
-
if (collsToBackup.includes(collection.name)) {
|
|
4239
|
-
const query = { _user: user.username };
|
|
4240
|
-
const args = [
|
|
4241
|
-
'--uri', dbUrl,
|
|
4242
|
-
'--db', dbName,
|
|
4243
|
-
'--out', localTempDumpDir,
|
|
4244
|
-
'--collection', collection.name,
|
|
4245
|
-
'--query', JSON.stringify(query)
|
|
4246
|
-
];
|
|
4247
|
-
logger.info(`Exécution de la commande : mongodump ${args.join(' ')}`);
|
|
4248
|
-
await execFileAsync('mongodump', args);
|
|
4249
|
-
}
|
|
4250
|
-
}
|
|
4251
|
-
const dumpSourceDir = path.join(localTempDumpDir, dbName);
|
|
4252
|
-
if (fs.existsSync(dumpSourceDir)) {
|
|
4253
|
-
await tar.create({ gzip: true, file: localArchivePath, C: localTempDumpDir }, [dbName]);
|
|
4254
|
-
logger.info(`Archive de sauvegarde locale créée : ${localArchivePath}`);
|
|
4255
|
-
} else {
|
|
4256
|
-
logger.warn(`Le répertoire de dump ${dumpSourceDir} était vide. Aucune archive n'a créée.`);
|
|
4257
|
-
return Promise.resolve();
|
|
4258
|
-
}
|
|
4259
|
-
|
|
4260
|
-
await runCryptoWorkerTask('encrypt', { filePath: localArchivePath, password: encryptedKey });
|
|
4261
|
-
|
|
4262
|
-
try {
|
|
4263
|
-
// Attempt the S3 upload
|
|
4264
|
-
await uploadToS3(s3Config, localArchivePath, finalArchiveName);
|
|
4265
|
-
// ONLY if the upload succeeds, delete the local file.
|
|
4266
|
-
fs.unlinkSync(localArchivePath);
|
|
4267
|
-
logger.info(`Local archive ${finalArchiveName} deleted after successful S3 upload.`);
|
|
4268
|
-
} catch (e) {
|
|
4269
|
-
}
|
|
4270
|
-
|
|
4271
|
-
logger.info(`Sauvegarde réussie pour l'utilisateur ${user.username}.`);
|
|
4272
|
-
await manageBackupRotation(user, await engine.userProvider.getBackupFrequency(user), s3Config);
|
|
4273
|
-
|
|
4274
|
-
} catch (error) {
|
|
4275
|
-
logger.error(`Erreur lors de la sauvegarde pour l'utilisateur ${user.username}:`, error);
|
|
4276
|
-
// Nettoyage de l'archive si elle a été créée avant l'erreur
|
|
4277
|
-
if (fs.existsSync(localArchivePath)) {
|
|
4278
|
-
fs.unlinkSync(localArchivePath);
|
|
4279
|
-
}
|
|
4280
|
-
throw error; // Relancer l'erreur pour que l'appelant soit informé
|
|
4281
|
-
} finally {
|
|
4282
|
-
// --- NETTOYAGE GARANTI ---
|
|
4283
|
-
// Ce bloc s'exécute toujours, que la sauvegarde réussisse ou échoue.
|
|
4284
|
-
if (fs.existsSync(localTempDumpDir)) {
|
|
4285
|
-
fs.rmSync(localTempDumpDir, { recursive: true, force: true });
|
|
4286
|
-
logger.info(`Répertoire de dump temporaire ${localTempDumpDir} supprimé.`);
|
|
4287
|
-
}
|
|
4288
|
-
}
|
|
4289
|
-
};
|
|
4290
|
-
async function manageBackupRotation(user, backupFrequency, s3Config = null) { // Accepter s3Config
|
|
4291
|
-
const userId = getObjectHash({user:user.username});
|
|
4292
|
-
let filesToManage = [];
|
|
4293
|
-
|
|
4294
|
-
if (s3Config && s3Config.bucketName && s3Config.accessKeyId && s3Config.secretAccessKey) {
|
|
4295
|
-
logger.info(`Gestion de la rotation des sauvegardes S3 pour ${userId}.`);
|
|
4296
|
-
const s3Backups = await listS3Backups(s3Config);
|
|
4297
|
-
// Filtrer pour ne garder que les backups de cet utilisateur et trier
|
|
4298
|
-
filesToManage = s3Backups
|
|
4299
|
-
.filter(f => f.filename.startsWith(`backup_${userId}_`) && f.filename.endsWith('.tar.gz'))
|
|
4300
|
-
.map(f => ({ name: f.filename, key: f.key, timestamp: f.timestamp })) // listS3Backups devrait fournir le timestamp
|
|
4301
|
-
.sort((a, b) => b.timestamp - a.timestamp); // Tri décroissant (plus récent en premier)
|
|
4302
|
-
|
|
4303
|
-
} else {
|
|
4304
|
-
logger.info(`Gestion de la rotation des sauvegardes locales pour ${userId}.`);
|
|
4305
|
-
const backupDir = getBackupDir();
|
|
4306
|
-
const localFiles = fs.readdirSync(backupDir);
|
|
4307
|
-
filesToManage = localFiles
|
|
4308
|
-
.filter(f => !fs.lstatSync(path.join(backupDir, f)).isDirectory() && f.startsWith(`backup_${userId}_`) && f.endsWith('.tar.gz'))
|
|
4309
|
-
.map(f => {
|
|
4310
|
-
const match = f.match(/_(\d+)\.tar\.gz$/);
|
|
4311
|
-
return { name: f, key: path.join(backupDir, f), timestamp: match ? parseInt(match[1], 10) : 0 };
|
|
4312
|
-
})
|
|
4313
|
-
.sort((a, b) => b.timestamp - a.timestamp); // Tri décroissant
|
|
4314
|
-
}
|
|
4315
|
-
|
|
4316
|
-
let maxFilesToKeep;
|
|
4317
|
-
// ... (ta logique existante pour maxFilesToKeep basée sur backupFrequency)
|
|
4318
|
-
switch (backupFrequency) {
|
|
4319
|
-
case 'daily': // Premium
|
|
4320
|
-
maxFilesToKeep = 7; // Garder 7 jours
|
|
4321
|
-
break;
|
|
4322
|
-
case 'weekly': // Standard
|
|
4323
|
-
maxFilesToKeep = 4; // Garder 4 semaines
|
|
4324
|
-
break;
|
|
4325
|
-
case 'monthly': // Free
|
|
4326
|
-
default:
|
|
4327
|
-
maxFilesToKeep = 2; // Garder 2 mois
|
|
4328
|
-
break;
|
|
4329
|
-
}
|
|
4330
|
-
logger.info(`Rotation pour ${userId}: fréquence ${backupFrequency}, garde ${maxFilesToKeep} sauvegardes.`);
|
|
4331
|
-
|
|
4332
|
-
|
|
4333
|
-
if (filesToManage.length > maxFilesToKeep) {
|
|
4334
|
-
const filesToDelete = filesToManage.slice(maxFilesToKeep);
|
|
4335
|
-
logger.info(`Suppression de ${filesToDelete.length} anciennes sauvegardes pour ${userId}.`);
|
|
4336
|
-
|
|
4337
|
-
const deletionPromises = filesToDelete.map(async (fileInfo) => {
|
|
4338
|
-
try {
|
|
4339
|
-
if (s3Config && s3Config.bucketName) {
|
|
4340
|
-
const s3 = new AWS.S3({ /* ... config ... */ accessKeyId: s3Config.accessKeyId, secretAccessKey: s3Config.secretAccessKey, region: s3Config.region });
|
|
4341
|
-
await s3.deleteObject({ Bucket: s3Config.bucketName, Key: fileInfo.key }).promise();
|
|
4342
|
-
logger.info(`Ancienne sauvegarde S3 supprimée : ${fileInfo.key}`);
|
|
4343
|
-
} else {
|
|
4344
|
-
await fs.promises.unlink(fileInfo.key); // key est le chemin complet pour les fichiers locaux
|
|
4345
|
-
logger.info(`Ancienne sauvegarde locale supprimée : ${fileInfo.name}`);
|
|
4346
|
-
}
|
|
4347
|
-
} catch (err) {
|
|
4348
|
-
logger.error(`Erreur lors de la suppression de l'ancienne sauvegarde ${fileInfo.name || fileInfo.key}:`, err);
|
|
4349
|
-
}
|
|
4350
|
-
});
|
|
4351
|
-
await Promise.allSettled(deletionPromises);
|
|
4352
|
-
} else {
|
|
4353
|
-
logger.info(`Aucune ancienne sauvegarde à supprimer pour ${userId} (total: ${filesToManage.length}, garde: ${maxFilesToKeep}).`);
|
|
4354
|
-
}
|
|
4355
|
-
}
|
|
4356
|
-
|
|
4357
|
-
/**
|
|
4358
|
-
* Installs pack models and data for a user or globally.
|
|
4359
|
-
* Can accept either a pack ID (to install from database) or a direct pack JSON object.
|
|
4360
|
-
*
|
|
4361
|
-
* @param {object} logger - Logger instance
|
|
4362
|
-
* @param {string|object} packIdentifier - Either pack ID (string) or pack JSON object
|
|
4363
|
-
* @param {object|null} user - User object (if installing for user) or null (for global install)
|
|
4364
|
-
* @param {string} [lang='en'] - Language code for localized data
|
|
4365
|
-
* @returns {Promise<{success: boolean, summary: object, errors: Array, modifiedCount: number}>}
|
|
4366
|
-
*/
|
|
4367
|
-
export async function installPack(packIdentifier, user = null, lang = 'en', options = {}) {
|
|
4368
|
-
let pack;
|
|
4369
|
-
const packsCollection = getCollection('packs');
|
|
4370
|
-
|
|
4371
|
-
// Determine if we're working with an ID or direct pack object
|
|
4372
|
-
if (typeof packIdentifier === 'string') {
|
|
4373
|
-
let p;
|
|
4374
|
-
try {
|
|
4375
|
-
p = new ObjectId(packIdentifier);
|
|
4376
|
-
} catch (e) {
|
|
4377
|
-
p = packIdentifier;
|
|
4378
|
-
}
|
|
4379
|
-
// Existing behavior - fetch from database
|
|
4380
|
-
pack = await packsCollection.findOne({ $and: [{ _user: {$exists: false} }, {private: false}, {$or:[{ _id: p}, { name: packIdentifier }]}]} );
|
|
4381
|
-
if (!pack) {
|
|
4382
|
-
throw new Error(`Pack with ID ${packIdentifier} not found.`);
|
|
4383
|
-
}
|
|
4384
|
-
} else if (typeof packIdentifier === 'object' && packIdentifier !== null) {
|
|
4385
|
-
// New behavior - use provided pack object directly
|
|
4386
|
-
pack = packIdentifier;
|
|
4387
|
-
|
|
4388
|
-
// Validate basic pack structure
|
|
4389
|
-
if (!pack.name || (!pack.models && !pack.data)) {
|
|
4390
|
-
throw new Error('Invalid pack structure - must contain at least name and models or data');
|
|
4391
|
-
}
|
|
4392
|
-
} else {
|
|
4393
|
-
throw new Error('Invalid pack identifier - must be either pack ID string or pack object');
|
|
4394
|
-
}
|
|
4395
|
-
|
|
4396
|
-
const username = user ? user.username : null;
|
|
4397
|
-
const logPrefix = username
|
|
4398
|
-
? `Installing pack '${pack.name}' for user '${username}'`
|
|
4399
|
-
: `Installing pack '${pack.name}' globally`;
|
|
4400
|
-
|
|
4401
|
-
logger.info(`--- ${logPrefix} ---`);
|
|
4402
|
-
|
|
4403
|
-
const summary = {
|
|
4404
|
-
models: { installed: [], skipped: [], failed: [] },
|
|
4405
|
-
datas: { inserted: 0, updated: 0, skipped: 0, failed: 0 }
|
|
4406
|
-
};
|
|
4407
|
-
const errors = [];
|
|
4408
|
-
const collection = user ? await getCollectionForUser(user) : getCollection('data');
|
|
4409
|
-
const tempIdToNewIdMap = {};
|
|
4410
|
-
const linkCache = new Map();
|
|
4411
|
-
|
|
4412
|
-
// --- PHASE 1: MODEL INSTALLATION ---
|
|
4413
|
-
if (Array.isArray(pack.models)) {
|
|
4414
|
-
// For user installs, check existing models
|
|
4415
|
-
const existingModels = user
|
|
4416
|
-
? await modelsCollection.find({ _user: username }).toArray()
|
|
4417
|
-
: await modelsCollection.find({ _user: { $exists: false } }).toArray();
|
|
4418
|
-
|
|
4419
|
-
console.log("EXISTING", existingModels);
|
|
4420
|
-
|
|
4421
|
-
const existingModelNames = existingModels.map(m => m.name);
|
|
4422
|
-
|
|
4423
|
-
for (const modelOrName of pack.models) {
|
|
4424
|
-
try {
|
|
4425
|
-
const modelName = typeof modelOrName === 'string' ? modelOrName : modelOrName?.name;
|
|
4426
|
-
if (!modelName) throw new Error('Model definition in pack is missing a name.');
|
|
4427
|
-
|
|
4428
|
-
if (existingModelNames.includes(modelName)) {
|
|
4429
|
-
logger.debug(`[Model Install] Skipping '${modelName}': already exists`);
|
|
4430
|
-
summary.models.skipped.push(modelName);
|
|
4431
|
-
continue;
|
|
4432
|
-
}
|
|
4433
|
-
|
|
4434
|
-
const modelToInstall = typeof modelOrName === 'string'
|
|
4435
|
-
? await modelsCollection.findOne({ name: modelName, _user: { $exists: false } })
|
|
4436
|
-
: { ...modelOrName };
|
|
4437
|
-
|
|
4438
|
-
if (!modelToInstall) {
|
|
4439
|
-
throw new Error(`Model '${modelName}' not found in shared models`);
|
|
4440
|
-
}
|
|
4441
|
-
|
|
4442
|
-
// Prepare model for installation
|
|
4443
|
-
const preparedModel = { ...modelToInstall };
|
|
4444
|
-
if (user) preparedModel._user = username;
|
|
4445
|
-
delete preparedModel._id;
|
|
4446
|
-
preparedModel.locked = false;
|
|
4447
|
-
|
|
4448
|
-
if (preparedModel.fields) {
|
|
4449
|
-
preparedModel.fields.forEach(f => f.locked = false);
|
|
4450
|
-
}
|
|
4451
|
-
|
|
4452
|
-
await validateModelStructure(preparedModel);
|
|
4453
|
-
await modelsCollection.insertOne(preparedModel);
|
|
4454
|
-
summary.models.installed.push(modelName);
|
|
4455
|
-
|
|
4456
|
-
} catch (e) {
|
|
4457
|
-
const modelName = typeof modelOrName === 'string' ? modelOrName : modelOrName?.name || 'unknown';
|
|
4458
|
-
errors.push(`Failed to install model '${modelName}': ${e.message}`);
|
|
4459
|
-
summary.models.failed.push(modelName);
|
|
4460
|
-
}
|
|
4461
|
-
}
|
|
4462
|
-
}
|
|
4463
|
-
|
|
4464
|
-
// --- PHASE 2: DATA INSTALLATION ---
|
|
4465
|
-
const dataToInstall = { ...pack.data?.all, ...pack.data?.[lang] };
|
|
4466
|
-
if (!dataToInstall || Object.keys(dataToInstall).length === 0) {
|
|
4467
|
-
logger.warn(`Pack '${pack.name}' has no data to install.`);
|
|
4468
|
-
return { success: false, summary, errors, modifiedCount: 0 };
|
|
4469
|
-
}
|
|
4470
|
-
|
|
4471
|
-
// Process link references (same as original)
|
|
4472
|
-
const linkQueue = [];
|
|
4473
|
-
for (const modelName in dataToInstall) {
|
|
4474
|
-
if (Array.isArray(dataToInstall[modelName])) {
|
|
4475
|
-
for (const docSource of dataToInstall[modelName]) {
|
|
4476
|
-
const tempId = new ObjectId().toString();
|
|
4477
|
-
docSource._temp_pack_id = tempId;
|
|
4478
|
-
|
|
4479
|
-
for (const fieldName in docSource) {
|
|
4480
|
-
if (isPlainObject(docSource[fieldName]) && docSource[fieldName].$link) {
|
|
4481
|
-
linkQueue.push({
|
|
4482
|
-
sourceTempId: tempId,
|
|
4483
|
-
sourceModelName: modelName,
|
|
4484
|
-
fieldName,
|
|
4485
|
-
linkSelector: docSource[fieldName].$link
|
|
4486
|
-
});
|
|
4487
|
-
}
|
|
4488
|
-
}
|
|
4489
|
-
}
|
|
4490
|
-
}
|
|
4491
|
-
}
|
|
4492
|
-
|
|
4493
|
-
// --- PASS 1: BATCH INSERTION ---
|
|
4494
|
-
logger.info("[Pack Install] Starting Pass 1: Batch Insertion & ID Mapping");
|
|
4495
|
-
for (const modelName in dataToInstall) {
|
|
4496
|
-
if (!Array.isArray(dataToInstall[modelName])) continue;
|
|
4497
|
-
|
|
4498
|
-
const documents = dataToInstall[modelName];
|
|
4499
|
-
if (documents.length === 0) continue;
|
|
4500
|
-
|
|
4501
|
-
const docsToInsert = [];
|
|
4502
|
-
const modelDefForHash = await getModel(modelName, user);
|
|
4503
|
-
|
|
4504
|
-
for (const docSource of documents) {
|
|
4505
|
-
let docForInsert = { ...docSource };
|
|
4506
|
-
|
|
4507
|
-
// Clear $link fields for first pass
|
|
4508
|
-
for (const key in docForInsert) {
|
|
4509
|
-
if (isPlainObject(docForInsert[key]) && docForInsert[key].$link) {
|
|
4510
|
-
docForInsert[key] = null;
|
|
4511
|
-
}
|
|
4512
|
-
}
|
|
4513
|
-
|
|
4514
|
-
const tempId = docForInsert._temp_pack_id;
|
|
4515
|
-
delete docForInsert._id;
|
|
4516
|
-
delete docForInsert._temp_pack_id;
|
|
4517
|
-
|
|
4518
|
-
if (user) docForInsert._user = username;
|
|
4519
|
-
docForInsert._model = modelName;
|
|
4520
|
-
docForInsert._hash = getFieldValueHash(modelDefForHash, docForInsert);
|
|
4521
|
-
|
|
4522
|
-
// Check for existing document
|
|
4523
|
-
const existingQuery = {
|
|
4524
|
-
_hash: docForInsert._hash,
|
|
4525
|
-
_model: modelName
|
|
4526
|
-
};
|
|
4527
|
-
if (user) existingQuery._user = username;
|
|
4528
|
-
|
|
4529
|
-
const existingDoc = await collection.findOne(existingQuery, { projection: { _id: 1 } });
|
|
4530
|
-
if (existingDoc) {
|
|
4531
|
-
tempIdToNewIdMap[tempId] = existingDoc._id;
|
|
4532
|
-
summary.datas.skipped++;
|
|
4533
|
-
} else {
|
|
4534
|
-
docForInsert._temp_pack_id_for_mapping = tempId;
|
|
4535
|
-
docsToInsert.push(docForInsert);
|
|
4536
|
-
}
|
|
4537
|
-
}
|
|
4538
|
-
|
|
4539
|
-
if (docsToInsert.length > 0) {
|
|
4540
|
-
try {
|
|
4541
|
-
const finalDocsToInsert = docsToInsert.map(d => {
|
|
4542
|
-
const doc = {...d};
|
|
4543
|
-
delete doc._temp_pack_id_for_mapping;
|
|
4544
|
-
return doc;
|
|
4545
|
-
});
|
|
4546
|
-
|
|
4547
|
-
const result = await collection.insertMany(finalDocsToInsert, { ordered: false });
|
|
4548
|
-
summary.datas.inserted += result.insertedCount;
|
|
4549
|
-
|
|
4550
|
-
docsToInsert.forEach((doc, index) => {
|
|
4551
|
-
if (result.insertedIds[index]) {
|
|
4552
|
-
tempIdToNewIdMap[doc._temp_pack_id_for_mapping] = result.insertedIds[index];
|
|
4553
|
-
}
|
|
4554
|
-
});
|
|
4555
|
-
} catch (e) {
|
|
4556
|
-
summary.datas.failed += docsToInsert.length;
|
|
4557
|
-
errors.push(`Error inserting batch for ${modelName}: ${e.message}`);
|
|
4558
|
-
logger.error(`[Pack Install] Error on insertMany for model ${modelName}:`, e);
|
|
4559
|
-
}
|
|
4560
|
-
}
|
|
4561
|
-
}
|
|
4562
|
-
|
|
4563
|
-
// --- PASS 2: REFERENCE LINKING ---
|
|
4564
|
-
logger.info(`[Pack Install] Starting Pass 2: Linking ${linkQueue.length} references`);
|
|
4565
|
-
for (const linkOp of linkQueue) {
|
|
4566
|
-
const { sourceTempId, sourceModelName, fieldName, linkSelector } = linkOp;
|
|
4567
|
-
const sourceId = tempIdToNewIdMap[sourceTempId];
|
|
4568
|
-
|
|
4569
|
-
if (!sourceId) {
|
|
4570
|
-
logger.warn(`[LINK FAILED] Could not find newly inserted document for temp ID ${sourceTempId}. Skipping link.`);
|
|
4571
|
-
continue;
|
|
4572
|
-
}
|
|
4573
|
-
|
|
4574
|
-
try {
|
|
4575
|
-
const targetModelName = linkSelector._model;
|
|
4576
|
-
delete linkSelector._model;
|
|
4577
|
-
|
|
4578
|
-
const sourceModelDef = await getModel(sourceModelName, user);
|
|
4579
|
-
const fieldDef = sourceModelDef.fields.find(f => f.name === fieldName);
|
|
4580
|
-
|
|
4581
|
-
if (!fieldDef) {
|
|
4582
|
-
logger.warn(`[LINK FAILED] Field '${fieldName}' not found in source model '${sourceModelName}'`);
|
|
4583
|
-
errors.push(`[LINK FAILED] Field '${fieldName}' not found in source model '${sourceModelName}'`);
|
|
4584
|
-
summary.datas.failed++;
|
|
4585
|
-
continue;
|
|
4586
|
-
}
|
|
4587
|
-
|
|
4588
|
-
// Search for target documents
|
|
4589
|
-
const { data: targetDocs } = await searchData(
|
|
4590
|
-
{ model: targetModelName, filter: linkSelector },
|
|
4591
|
-
user
|
|
4592
|
-
);
|
|
4593
|
-
|
|
4594
|
-
if (!targetDocs || targetDocs.length === 0) {
|
|
4595
|
-
const errorMsg = `[LINK FAILED] No target found for ${JSON.stringify(linkSelector)}`;
|
|
4596
|
-
logger.warn(errorMsg);
|
|
4597
|
-
errors.push(errorMsg);
|
|
4598
|
-
summary.datas.failed++;
|
|
4599
|
-
continue;
|
|
4600
|
-
}
|
|
4601
|
-
|
|
4602
|
-
// Update source document with reference
|
|
4603
|
-
const valueToSet = fieldDef.multiple
|
|
4604
|
-
? targetDocs.map(d => d._id.toString())
|
|
4605
|
-
: targetDocs[0]._id.toString();
|
|
4606
|
-
|
|
4607
|
-
await collection.updateOne(
|
|
4608
|
-
{ _id: sourceId },
|
|
4609
|
-
{ $set: { [fieldName]: valueToSet } }
|
|
4610
|
-
);
|
|
4611
|
-
summary.datas.updated++;
|
|
4612
|
-
|
|
4613
|
-
} catch (e) {
|
|
4614
|
-
const errorMsg = `[LINK CRITICAL] Error linking ${sourceModelName}.${fieldName}: ${e.message}`;
|
|
4615
|
-
logger.error(errorMsg, e.stack);
|
|
4616
|
-
errors.push(errorMsg);
|
|
4617
|
-
summary.datas.failed++;
|
|
4618
|
-
}
|
|
4619
|
-
}
|
|
4620
|
-
|
|
4621
|
-
if( options.installForUser && user?.username ){
|
|
4622
|
-
if( pack.name )
|
|
4623
|
-
await packsCollection.deleteOne({ name: pack.name, _user: user.username });
|
|
4624
|
-
logger.info(`--- Creating pack '${pack.name}' for user... ---`);
|
|
4625
|
-
const packToCreate = {...pack, _id: undefined, private: true, _user: user.username };
|
|
4626
|
-
await packsCollection.insertOne(packToCreate);
|
|
4627
|
-
}
|
|
4628
|
-
|
|
4629
|
-
// Trigger event only if pack came from database (original behavior)
|
|
4630
|
-
if (typeof packIdentifier === 'string') {
|
|
4631
|
-
await Event.Trigger("OnPackInstalled", "event", "system", pack);
|
|
4632
|
-
}
|
|
4633
|
-
|
|
4634
|
-
const modifiedCount = summary.datas.inserted + summary.datas.updated;
|
|
4635
|
-
logger.info(`--- ${logPrefix} completed ---`);
|
|
4636
|
-
return {
|
|
4637
|
-
success: errors.length === 0,
|
|
4638
|
-
summary,
|
|
4639
|
-
errors,
|
|
4640
|
-
modifiedCount
|
|
4641
|
-
};
|
|
4642
|
-
}
|
|
4643
|
-
|
|
4644
|
-
export const installAllPacks = async () => {
|
|
4645
|
-
const packs = await getAllPacks();
|
|
4646
|
-
await packsCollection.deleteMany({ _user: { $exists : false }});
|
|
4647
|
-
await packsCollection.insertMany(packs.map(p =>({...p, private: false })));
|
|
4648
|
-
}
|
|
4649
288
|
|
|
4650
289
|
export async function handleDemoInitialization(req, res) {
|
|
4651
290
|
const user = req.me;
|
|
@@ -4699,7 +338,7 @@ export async function handleDemoInitialization(req, res) {
|
|
|
4699
338
|
res.status(200).json({ success: true, message: "Demo environment initialized successfully.", summary: result.summary });
|
|
4700
339
|
} else {
|
|
4701
340
|
logger.error(`[Demo Init] Pack installation failed for user '${user.username}'.`);
|
|
4702
|
-
res.status(
|
|
341
|
+
res.status(200).json({ success: false, error: 'Demo pack installation failed.', errors: result.errors });
|
|
4703
342
|
}
|
|
4704
343
|
|
|
4705
344
|
} catch (error) {
|