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
|
@@ -0,0 +1,3000 @@
|
|
|
1
|
+
import {getObjectHash, getRand, getRandom, isPlainObject, randomDate, safeAssignObject, setSeed} from "../../core.js";
|
|
2
|
+
import {
|
|
3
|
+
maxExportCount,
|
|
4
|
+
maxFileSize,
|
|
5
|
+
maxFilterDepth,
|
|
6
|
+
maxModelNameLength,
|
|
7
|
+
maxPasswordLength,
|
|
8
|
+
maxPostData,
|
|
9
|
+
maxRelationsPerData,
|
|
10
|
+
maxRequestData,
|
|
11
|
+
maxRichTextLength,
|
|
12
|
+
maxStringLength,
|
|
13
|
+
maxTotalDataPerUser,
|
|
14
|
+
megabytes,
|
|
15
|
+
optionsSanitizer,
|
|
16
|
+
searchRequestTimeout
|
|
17
|
+
} from "../../constants.js";
|
|
18
|
+
import {anonymizeText, getFieldValueHash, getUserId, isDemoUser, isLocalUser} from "../../data.js";
|
|
19
|
+
import sanitizeHtml from "sanitize-html";
|
|
20
|
+
import {importJobs, modelsCache, runCryptoWorkerTask, runImportExportWorker} from "./data.core.js";
|
|
21
|
+
import {
|
|
22
|
+
getCollection,
|
|
23
|
+
getCollectionForUser,
|
|
24
|
+
getUserCollectionName,
|
|
25
|
+
isObjectId,
|
|
26
|
+
modelsCollection,
|
|
27
|
+
packsCollection
|
|
28
|
+
} from "../mongodb.js";
|
|
29
|
+
import i18n from "../../i18n.js";
|
|
30
|
+
import {randomColor} from "randomcolor";
|
|
31
|
+
import {Config} from "../../config.js";
|
|
32
|
+
import {calculateTotalUserStorageUsage, hasPermission} from "../user.js";
|
|
33
|
+
import {BSON, ObjectId} from "mongodb";
|
|
34
|
+
import {runScheduledJobWithDbLock, triggerWorkflows} from "../workflow.js";
|
|
35
|
+
import schedule from "node-schedule";
|
|
36
|
+
import {sendSseToUser} from "./data.routes.js";
|
|
37
|
+
import {applyCronMask, handleScheduledJobs, runStatefulAlertJob} from "./data.scheduling.js";
|
|
38
|
+
import {Event} from "../../events.js";
|
|
39
|
+
import {getAllPacks} from "../../packs.js";
|
|
40
|
+
import {validateModelData, validateModelStructure} from "./data.validation.js";
|
|
41
|
+
import {addFile, removeFile} from "../file.js";
|
|
42
|
+
import NodeCache from "node-cache";
|
|
43
|
+
import fs from "node:fs";
|
|
44
|
+
import readXlsxFile from "read-excel-file/node";
|
|
45
|
+
import {checkServerCapacity} from "./data.js";
|
|
46
|
+
import {Logger} from "../../gameObject.js";
|
|
47
|
+
import {
|
|
48
|
+
changeValue,
|
|
49
|
+
checkHash,
|
|
50
|
+
convertDataTypes,
|
|
51
|
+
handleFilesIfNeeded,
|
|
52
|
+
processDocuments,
|
|
53
|
+
processFileArray
|
|
54
|
+
} from "./data.relations.js";
|
|
55
|
+
import cronstrue from 'cronstrue/i18n.js';
|
|
56
|
+
|
|
57
|
+
const delay = ms => new Promise(res => setTimeout(res, ms));
|
|
58
|
+
const IMPORT_CHUNK_SIZE = 100; // Nombre d'enregistrements à traiter par lot
|
|
59
|
+
const IMPORT_CHUNK_DELAY_MS = 1000; // Délai en millisecondes entre le traitement des lots
|
|
60
|
+
|
|
61
|
+
export const dataTypes = {
|
|
62
|
+
object: {
|
|
63
|
+
validate: (value, field) => {
|
|
64
|
+
return value === null || isPlainObject(value);
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
model: {
|
|
68
|
+
validate: (value, field) => {
|
|
69
|
+
return value === null || typeof value === 'string' && value.length <= maxModelNameLength;
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
cronSchedule: {
|
|
73
|
+
validate: (value, field) => {
|
|
74
|
+
if (value === null)
|
|
75
|
+
return true;
|
|
76
|
+
try {
|
|
77
|
+
cronstrue.toString(value, {throwExceptionOnParseError: true});
|
|
78
|
+
return true;
|
|
79
|
+
} catch (e) {
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
},
|
|
83
|
+
filter: async (value, field) => {
|
|
84
|
+
if (value === null)
|
|
85
|
+
return null;
|
|
86
|
+
if (field.cronMask && field.default) {
|
|
87
|
+
return applyCronMask(value, field.cronMask, field.default);
|
|
88
|
+
}
|
|
89
|
+
return value;
|
|
90
|
+
}
|
|
91
|
+
},
|
|
92
|
+
modelField: {
|
|
93
|
+
validate: (value, field) => {
|
|
94
|
+
return value === null || typeof value === 'object' && JSON.stringify(value).length <= maxModelNameLength + 100;
|
|
95
|
+
}
|
|
96
|
+
},
|
|
97
|
+
string: {
|
|
98
|
+
validate: (value, field) => {
|
|
99
|
+
const ml = Math.min(Math.max(field.maxlength, 0), maxStringLength);
|
|
100
|
+
return value === null || typeof value === 'string' && (!ml || value.length <= ml)
|
|
101
|
+
},
|
|
102
|
+
anonymize: anonymizeText
|
|
103
|
+
},
|
|
104
|
+
code: {
|
|
105
|
+
validate: (value, field) => {
|
|
106
|
+
return value === null || (field.language === 'json' && typeof (value) === 'object') || (typeof value === 'string' && (field.maxlength === undefined || field.maxlength <= 0 || value.length <= field.maxlength));
|
|
107
|
+
},
|
|
108
|
+
filter: async (value, field) => {
|
|
109
|
+
if (field.language === 'json') {
|
|
110
|
+
if (typeof (value) === 'object')
|
|
111
|
+
return value;
|
|
112
|
+
else if (typeof (value) === 'string') {
|
|
113
|
+
try {
|
|
114
|
+
return JSON.parse(value);
|
|
115
|
+
} catch (e) {
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
} else {
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return value;
|
|
123
|
+
},
|
|
124
|
+
anonymize: (str) => anonymizeText(typeof (str) === 'object' ? JSON.stringify(str) : str)
|
|
125
|
+
},
|
|
126
|
+
richtext: {
|
|
127
|
+
validate: (value, field) => {
|
|
128
|
+
const ml = Math.min(Math.max(field.maxlength, 0), maxRichTextLength);
|
|
129
|
+
return value === null || typeof value === 'string' && (!ml || value.length <= ml)
|
|
130
|
+
},
|
|
131
|
+
filter: async (value) => {
|
|
132
|
+
return sanitizeHtml(value, optionsSanitizer);
|
|
133
|
+
},
|
|
134
|
+
anonymize: anonymizeText
|
|
135
|
+
},
|
|
136
|
+
'string_t': {
|
|
137
|
+
validate: (value, field) => {
|
|
138
|
+
if (value === null)
|
|
139
|
+
return true;
|
|
140
|
+
const ml = Math.min(Math.max(field.maxlength, 0), maxStringLength);
|
|
141
|
+
// La valeur peut être une chaîne de caractères...
|
|
142
|
+
if (typeof value === 'string') {
|
|
143
|
+
return !ml || value.length <= ml;
|
|
144
|
+
}
|
|
145
|
+
// ... ou un objet contenant une clé de type chaîne de caractères.
|
|
146
|
+
if (typeof value === 'object' &&
|
|
147
|
+
(typeof value.key === 'string' || value.key === null)) {
|
|
148
|
+
return !ml || value.key.length <= ml;
|
|
149
|
+
}
|
|
150
|
+
return false; // Invalide si ce n'est aucun des deux.
|
|
151
|
+
},
|
|
152
|
+
filter: async (value, field) => {
|
|
153
|
+
// Si la valeur est un objet avec une clé, on ne garde que la clé.
|
|
154
|
+
if (typeof value === 'object' && value !== null &&
|
|
155
|
+
(typeof value.key === 'string' || value.key === null)) {
|
|
156
|
+
return value.key || '';
|
|
157
|
+
}
|
|
158
|
+
// Sinon, on garde la valeur telle quelle (qui devrait être une chaîne).
|
|
159
|
+
return value;
|
|
160
|
+
},
|
|
161
|
+
anonymize: anonymizeText
|
|
162
|
+
},
|
|
163
|
+
password: {
|
|
164
|
+
filter: async (value) => {
|
|
165
|
+
if (value)
|
|
166
|
+
return await runCryptoWorkerTask('hash', {data: value});
|
|
167
|
+
return null;
|
|
168
|
+
},
|
|
169
|
+
validate: (value, field) => {
|
|
170
|
+
const ml = Math.min(Math.max(field.maxlength, 0), maxPasswordLength);
|
|
171
|
+
return value === null || typeof value === 'string' && (!ml || value.length <= ml)
|
|
172
|
+
},
|
|
173
|
+
anonymize: anonymizeText
|
|
174
|
+
},
|
|
175
|
+
date: {
|
|
176
|
+
validate: (value, field) => {
|
|
177
|
+
if (value === null)
|
|
178
|
+
return true;
|
|
179
|
+
if (typeof (value) === 'string' && value.toLowerCase() === 'now')
|
|
180
|
+
return true;
|
|
181
|
+
if (typeof value !== 'string') return false;
|
|
182
|
+
const dt = new Date(value);
|
|
183
|
+
|
|
184
|
+
const dtMin = new Date(field.min || value);
|
|
185
|
+
const dtMax = new Date(field.max || value);
|
|
186
|
+
if (isNaN(dt)) {
|
|
187
|
+
return false;
|
|
188
|
+
}
|
|
189
|
+
return (dt.getTime() >= dtMin.getTime() && dt.getTime() <= dtMax.getTime());
|
|
190
|
+
},
|
|
191
|
+
filter: async (value) => {
|
|
192
|
+
if (typeof (value) === 'string' && value.toLowerCase() === "now") {
|
|
193
|
+
return new Date().toISOString().split("T")[0];
|
|
194
|
+
}
|
|
195
|
+
if (value instanceof Date)
|
|
196
|
+
return value.toISOString().split("T")[0];
|
|
197
|
+
return value;
|
|
198
|
+
},
|
|
199
|
+
anonymize: (value, field) => {
|
|
200
|
+
const min = new Date();
|
|
201
|
+
const max = new Date();
|
|
202
|
+
min.setFullYear(min.getFullYear() - 1);
|
|
203
|
+
max.setFullYear(max.getFullYear() + 1);
|
|
204
|
+
return randomDate(field.min ? new Date(field.min) : min, field.max ? new Date(field.max) : max);
|
|
205
|
+
}
|
|
206
|
+
},
|
|
207
|
+
datetime: {
|
|
208
|
+
validate: (value, field) => {
|
|
209
|
+
if (typeof (value) === 'string' && value.toLowerCase() === 'now')
|
|
210
|
+
return true;
|
|
211
|
+
if (value instanceof Date || value === null)
|
|
212
|
+
return true;
|
|
213
|
+
if (typeof value !== 'string' || !value) return false;
|
|
214
|
+
const dt = new Date(value);
|
|
215
|
+
const dtMin = new Date(field.min || value);
|
|
216
|
+
const dtMax = new Date(field.max || value);
|
|
217
|
+
if (isNaN(dt)) {
|
|
218
|
+
return false;
|
|
219
|
+
}
|
|
220
|
+
return (dt.getTime() >= dtMin.getTime() && dt.getTime() <= dtMax.getTime());
|
|
221
|
+
},
|
|
222
|
+
filter: async (value) => {
|
|
223
|
+
if (typeof (value) === 'string' && value.toLowerCase() === "now") {
|
|
224
|
+
return new Date().toISOString();
|
|
225
|
+
}
|
|
226
|
+
if (value instanceof Date)
|
|
227
|
+
return value.toISOString();
|
|
228
|
+
return value;
|
|
229
|
+
},
|
|
230
|
+
anonymize: (value, field) => {
|
|
231
|
+
const min = new Date();
|
|
232
|
+
const max = new Date();
|
|
233
|
+
min.setFullYear(min.getFullYear() - 1);
|
|
234
|
+
max.setFullYear(max.getFullYear() + 1);
|
|
235
|
+
return randomDate(field.min ? new Date(field.min) : min, field.max ? new Date(field.max) : max);
|
|
236
|
+
}
|
|
237
|
+
},
|
|
238
|
+
phone: {
|
|
239
|
+
prefixRegex: /^[+]?[(]?[0-9]{2,3}[)]?$/,
|
|
240
|
+
validate: (value) => {
|
|
241
|
+
if (value === null) return true;
|
|
242
|
+
if (typeof value !== 'string') return false;
|
|
243
|
+
if (!value) return true;
|
|
244
|
+
if (dataTypes.phone.prefixRegex.test(value)) return true;
|
|
245
|
+
const phoneRegex = /^[+]?[(]?[0-9]{2,3}[)]?[-\s.]?[0-9]{3}[-\s.]?[0-9]{4,6}$/im;
|
|
246
|
+
return phoneRegex.test(value);
|
|
247
|
+
},
|
|
248
|
+
filter: async (value) => {
|
|
249
|
+
if (dataTypes.phone.prefixRegex.test(value)) return '';
|
|
250
|
+
return value;
|
|
251
|
+
},
|
|
252
|
+
anonymize: anonymizeText
|
|
253
|
+
},
|
|
254
|
+
url: {
|
|
255
|
+
validate: (value) => {
|
|
256
|
+
if (value === null) return true;
|
|
257
|
+
if (typeof value !== 'string') return false;
|
|
258
|
+
if (!value.trim()) return true;
|
|
259
|
+
const expression = /https?:\/\/(www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&/=]*)/;
|
|
260
|
+
return expression.test(value);
|
|
261
|
+
},
|
|
262
|
+
anonymize: anonymizeText
|
|
263
|
+
},
|
|
264
|
+
number: {
|
|
265
|
+
validate: (value, field) => {
|
|
266
|
+
if (value === null) return true;
|
|
267
|
+
const min = typeof (field.min) === 'number' ? field.min : null;
|
|
268
|
+
const max = typeof (field.max) === 'number' ? field.max : null;
|
|
269
|
+
if (min !== null && max !== null && max < min)
|
|
270
|
+
return false;
|
|
271
|
+
return typeof value === 'number' && !isNaN(value) && (min === null || value >= min) && (max === null || value <= max);
|
|
272
|
+
},
|
|
273
|
+
anonymize: (value, field) => {
|
|
274
|
+
const min = typeof (field.min) === 'number' ? field.min : 0;
|
|
275
|
+
const max = typeof (field.max) === 'number' ? field.max : Math.MAX_SAFE_INTEGER;
|
|
276
|
+
return getRandom(min, max);
|
|
277
|
+
}
|
|
278
|
+
},
|
|
279
|
+
boolean: {
|
|
280
|
+
validate: (value) => value === null || typeof value === 'boolean',
|
|
281
|
+
anonymize: () => {
|
|
282
|
+
return !!getRandom(0, 1);
|
|
283
|
+
}
|
|
284
|
+
},
|
|
285
|
+
array: {
|
|
286
|
+
validate: (value, field) => {
|
|
287
|
+
if (value === null) return true;
|
|
288
|
+
if (!Array.isArray(value)) {
|
|
289
|
+
return false;
|
|
290
|
+
}
|
|
291
|
+
if (field.minItems && value.length < field.minItems) {
|
|
292
|
+
return false;
|
|
293
|
+
}
|
|
294
|
+
if (field.maxItems && value.length > field.maxItems) {
|
|
295
|
+
return false;
|
|
296
|
+
}
|
|
297
|
+
return value.every(item => {
|
|
298
|
+
if (!dataTypes[field.itemsType]) {
|
|
299
|
+
throw new Error(`Invalid itemsType: ${field.itemsType}`);
|
|
300
|
+
}
|
|
301
|
+
return dataTypes[field.itemsType].validate(item, {field, type: field.itemsType});
|
|
302
|
+
});
|
|
303
|
+
},
|
|
304
|
+
anonymize: () => []
|
|
305
|
+
},
|
|
306
|
+
enum: {
|
|
307
|
+
validate: (value) => value === null || typeof (value) === 'string',
|
|
308
|
+
anonymize: (value, field) => {
|
|
309
|
+
return field.items[Math.floor(Math.random() * field.items.length)];
|
|
310
|
+
}
|
|
311
|
+
},
|
|
312
|
+
// Types personnalisés
|
|
313
|
+
email: {
|
|
314
|
+
validate: (value) => !value || /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value),
|
|
315
|
+
anonymize: anonymizeText
|
|
316
|
+
},
|
|
317
|
+
relation: {
|
|
318
|
+
validate: (value, field) => {
|
|
319
|
+
if (field.multiple) {
|
|
320
|
+
return typeof (value) === 'object' || (Array.isArray(value) && value.length <= maxRelationsPerData && !value.some(v => {
|
|
321
|
+
return !isObjectId(v);
|
|
322
|
+
}));
|
|
323
|
+
}
|
|
324
|
+
return value === null || value === undefined || isObjectId(value) || typeof (value) === 'object';
|
|
325
|
+
},
|
|
326
|
+
anonymize: () => null
|
|
327
|
+
},
|
|
328
|
+
file: {
|
|
329
|
+
validate: (value, field) => {
|
|
330
|
+
// If no file is selected, it's considered valid (optional field)
|
|
331
|
+
if (value === null || value === undefined) {
|
|
332
|
+
return true;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// Check if the value is a string (filename or GUID)
|
|
336
|
+
if (typeof value === 'string') {
|
|
337
|
+
return true;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// Check if the value is a File object
|
|
341
|
+
if (typeof (value) === 'object') {
|
|
342
|
+
if (field.mimeTypes && !field.mimeTypes.includes(value.type)) {
|
|
343
|
+
throw new Error(i18n.t('api.validate.invalidMimeType', {
|
|
344
|
+
type: value.type,
|
|
345
|
+
authorized: field.mimeTypes.join(', ')
|
|
346
|
+
}));
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// Check if the file size is within the limit
|
|
350
|
+
if (value.size > (field.maxSize || maxFileSize)) {
|
|
351
|
+
return false;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
return true;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
return false; // Invalid type
|
|
358
|
+
},
|
|
359
|
+
filter: async (value, field, reqFile) => {
|
|
360
|
+
if (typeof value !== 'object') {
|
|
361
|
+
return null;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
return value;
|
|
365
|
+
},
|
|
366
|
+
anonymize: () => null
|
|
367
|
+
},
|
|
368
|
+
color: {
|
|
369
|
+
validate: (value) => {
|
|
370
|
+
// Vérification si la valeur est une chaîne de caractères et correspond à un format de couleur hexadécimal valide.
|
|
371
|
+
return value === null || typeof value === 'string' && /^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/.test(value);
|
|
372
|
+
},
|
|
373
|
+
filter: async (value) => {
|
|
374
|
+
// Nettoyage ou transformation de la valeur si nécessaire (par exemple, mise en majuscule des caractères hexadécimaux).
|
|
375
|
+
return value ? value.toUpperCase() : null; // Retourne null si la valeur est null ou undefined
|
|
376
|
+
},
|
|
377
|
+
anonymize: () => {
|
|
378
|
+
return randomColor({
|
|
379
|
+
format: 'hex'
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
},
|
|
383
|
+
calculated: {
|
|
384
|
+
validate: (value) => {
|
|
385
|
+
return value !== undefined && value !== null && typeof value !== 'object' && !Array.isArray(value);
|
|
386
|
+
},
|
|
387
|
+
filter: async (value) => {
|
|
388
|
+
return value;
|
|
389
|
+
}
|
|
390
|
+
},
|
|
391
|
+
geolocation: {
|
|
392
|
+
validate: (value) => {
|
|
393
|
+
if (value === null) return true;
|
|
394
|
+
// Basic GeoJSON structure validation
|
|
395
|
+
if (typeof value !== 'object' || !value.type || !value.coordinates) {
|
|
396
|
+
return false;
|
|
397
|
+
}
|
|
398
|
+
// For now, we only validate 'Point' type, which is the most common.
|
|
399
|
+
if (value.type !== 'Point') {
|
|
400
|
+
// This can be extended to support 'Polygon', 'LineString', etc.
|
|
401
|
+
return false;
|
|
402
|
+
}
|
|
403
|
+
// Validate coordinates for a Point
|
|
404
|
+
return Array.isArray(value.coordinates) &&
|
|
405
|
+
value.coordinates.length === 2 &&
|
|
406
|
+
typeof value.coordinates[0] === 'number' &&
|
|
407
|
+
typeof value.coordinates[1] === 'number';
|
|
408
|
+
},
|
|
409
|
+
anonymize: () => {
|
|
410
|
+
// Generate random coordinates for a GeoJSON Point.
|
|
411
|
+
// Longitude: -180 to 180
|
|
412
|
+
// Latitude: -90 to 90
|
|
413
|
+
setSeed(new Date().getTime()+'');
|
|
414
|
+
const longitude = (getRand() * 360) - 180;
|
|
415
|
+
const latitude = (getRand() * 180) - 90;
|
|
416
|
+
return {
|
|
417
|
+
type: 'Point',
|
|
418
|
+
coordinates: [longitude, latitude]
|
|
419
|
+
};
|
|
420
|
+
}
|
|
421
|
+
},
|
|
422
|
+
richtext_t: {
|
|
423
|
+
validate: (value, field) => {
|
|
424
|
+
// La valeur doit être un objet (ou null/undefined)
|
|
425
|
+
if (value === null || typeof value === 'undefined') return true;
|
|
426
|
+
if (typeof value !== 'object' || Array.isArray(value)) return false;
|
|
427
|
+
|
|
428
|
+
// Chaque valeur dans l'objet doit être une chaîne de caractères (le HTML)
|
|
429
|
+
return Object.values(value).every(html => typeof html === 'string');
|
|
430
|
+
},
|
|
431
|
+
filter: async (value) => {
|
|
432
|
+
if (!value) return null;
|
|
433
|
+
const sanitizedObject = {};
|
|
434
|
+
for (const lang in value) {
|
|
435
|
+
if (Object.prototype.hasOwnProperty.call(value, lang)) {
|
|
436
|
+
// On réutilise le même sanitizer que pour le richtext simple
|
|
437
|
+
safeAssignObject(sanitizedObject, lang, sanitizeHtml(value[lang], optionsSanitizer));
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
return sanitizedObject;
|
|
441
|
+
},
|
|
442
|
+
anonymize: () => ({}) // Anonymisation en objet vide
|
|
443
|
+
}
|
|
444
|
+
};
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
let engine, logger;
|
|
449
|
+
export function onInit(defaultEngine) {
|
|
450
|
+
engine = defaultEngine;
|
|
451
|
+
logger = engine.getComponent(Logger);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
export const editModel = async (user, id, data) => {
|
|
455
|
+
|
|
456
|
+
if (!(isDemoUser(user) && Config.Get("useDemoAccounts")) && isLocalUser(user) && !await hasPermission(["API_ADMIN", "API_EDIT_MODEL"], user)) {
|
|
457
|
+
return ({success: false, error: i18n.t('api.permission.editModel', 'Cannot edit models from the API')})
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
const dataModel = data;
|
|
461
|
+
try {
|
|
462
|
+
const collection = await getCollectionForUser(user);
|
|
463
|
+
await validateModelStructure(dataModel);
|
|
464
|
+
|
|
465
|
+
const el = await modelsCollection.findOne({
|
|
466
|
+
$and: [
|
|
467
|
+
{_user: {$exists: true}},
|
|
468
|
+
{_id: new ObjectId(id)},
|
|
469
|
+
{
|
|
470
|
+
$and: [{_user: {$exists: true}}, {$or: [{_user: user._user}, {_user: user.username}]}]
|
|
471
|
+
}
|
|
472
|
+
]
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
if (!el) {
|
|
476
|
+
return ({success: false, statusCode: 404, error: i18n.t("api.model.notFound", {model: dataModel.name})});
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// renommage du modèle
|
|
480
|
+
if (typeof (data.name) === 'string' && el.name !== data.name && data.name) {
|
|
481
|
+
await collection.updateMany({_model: el.name}, {$set: {_model: data.name}});
|
|
482
|
+
await modelsCollection.updateMany({
|
|
483
|
+
'fields': {
|
|
484
|
+
'$elemMatch': {relation: el.name}
|
|
485
|
+
}
|
|
486
|
+
}, {
|
|
487
|
+
$set: {
|
|
488
|
+
'fields.$.relation': data.name
|
|
489
|
+
}
|
|
490
|
+
})
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// Update indexes
|
|
494
|
+
if (await engine.userProvider.hasFeature(user, 'indexes')) {
|
|
495
|
+
const coll = await getCollectionForUser(user);
|
|
496
|
+
let indexes = [];
|
|
497
|
+
try {
|
|
498
|
+
indexes = await coll.indexes();
|
|
499
|
+
} catch (e) {
|
|
500
|
+
if (e.codeName !== 'NamespaceNotFound') {
|
|
501
|
+
throw e;
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
const oldFields = el.fields || [];
|
|
506
|
+
const newFields = data.fields || [];
|
|
507
|
+
|
|
508
|
+
// --- Text Index Management (Compound) ---
|
|
509
|
+
const newTextFields = newFields.filter(f => f.index && f.indexType === 'text').map(f => f.name).sort();
|
|
510
|
+
const textIndexName = `_text_search_idx_${data.name}`;
|
|
511
|
+
const existingTextIndex = indexes.find(i => i.name === textIndexName);
|
|
512
|
+
const existingTextFields = existingTextIndex ? Object.keys(existingTextIndex.weights || {}).sort() : [];
|
|
513
|
+
|
|
514
|
+
// Check if the text index definition has changed
|
|
515
|
+
const textIndexChanged = JSON.stringify(existingTextFields) !== JSON.stringify(newTextFields);
|
|
516
|
+
|
|
517
|
+
if (textIndexChanged) {
|
|
518
|
+
if (existingTextIndex) {
|
|
519
|
+
logger.info(`[Index] Dropping existing text index '${textIndexName}' due to changes.`);
|
|
520
|
+
await coll.dropIndex(textIndexName);
|
|
521
|
+
}
|
|
522
|
+
if (newTextFields.length > 0) {
|
|
523
|
+
const textIndexSpec = newTextFields.reduce((acc, fieldName) => {
|
|
524
|
+
acc[fieldName] = 'text';
|
|
525
|
+
return acc;
|
|
526
|
+
}, {});
|
|
527
|
+
const indexOptions = {
|
|
528
|
+
name: textIndexName,
|
|
529
|
+
partialFilterExpression: { _model: data.name, _user: user.username }
|
|
530
|
+
};
|
|
531
|
+
logger.info(`[Index] Creating compound text index on fields: [${newTextFields.join(', ')}].`);
|
|
532
|
+
await coll.createIndex(textIndexSpec, indexOptions);
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
// --- Regular and 2dsphere Index Management (per field) ---
|
|
537
|
+
const managedFields = newFields.concat(oldFields.filter(oldField => !newFields.some(nf => nf.name === oldField.name)));
|
|
538
|
+
|
|
539
|
+
for (const field of managedFields) {
|
|
540
|
+
const oldField = oldFields.find(f => f.name === field.name);
|
|
541
|
+
const newField = newFields.find(f => f.name === field.name);
|
|
542
|
+
const fieldName = field.name;
|
|
543
|
+
|
|
544
|
+
// Skip text fields, they are handled above
|
|
545
|
+
if ((oldField?.indexType === 'text') || (newField?.indexType === 'text')) continue;
|
|
546
|
+
|
|
547
|
+
const wasIndexed = oldField?.index;
|
|
548
|
+
const isIndexed = newField?.index ?? false;
|
|
549
|
+
const oldIndexType = oldField?.indexType || 'regular';
|
|
550
|
+
const newIndexType = newField?.indexType || 'regular';
|
|
551
|
+
const indexName = `${fieldName}_${newIndexType}_idx`;
|
|
552
|
+
const existingIndex = indexes.find(i => i.key[fieldName] && i.name.startsWith(fieldName));
|
|
553
|
+
const indexExists = !!existingIndex;
|
|
554
|
+
const existingIndexTypeFromName = indexExists ? existingIndex.name.split('_')[1] : null;
|
|
555
|
+
|
|
556
|
+
const needsUpdate = !newField || (isIndexed !== indexExists) || (isIndexed && indexExists && newIndexType !== existingIndexTypeFromName);
|
|
557
|
+
|
|
558
|
+
if (needsUpdate) {
|
|
559
|
+
if (existingIndex) {
|
|
560
|
+
logger.info(`[Index] Dropping existing index '${existingIndex.name}' for field '${fieldName}' due to changes or deletion.`);
|
|
561
|
+
await coll.dropIndex(existingIndex.name);
|
|
562
|
+
}
|
|
563
|
+
if (isIndexed && newField) {
|
|
564
|
+
const indexValue = newIndexType === '2dsphere' ? '2dsphere' : 1;
|
|
565
|
+
const indexSpec = { [fieldName]: indexValue };
|
|
566
|
+
const indexOptions = { name: indexName, partialFilterExpression: { _model: data.name, _user: user.username } };
|
|
567
|
+
logger.info(`[Index] Creating '${newIndexType}' index on field '${fieldName}'.`);
|
|
568
|
+
await coll.createIndex(indexSpec, indexOptions);
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
// suppression des données à la suppression des champs
|
|
574
|
+
const unset = {};
|
|
575
|
+
el.fields.filter(f => !dataModel.fields.some(dt => dt.name === f.name)).map(f => f.name).forEach(f => {
|
|
576
|
+
unset[f] = 1;
|
|
577
|
+
});
|
|
578
|
+
await collection.updateMany({_model: el.name}, {$unset: unset});
|
|
579
|
+
|
|
580
|
+
// sauvegarde du modele
|
|
581
|
+
const set = {...data};
|
|
582
|
+
delete set['_id'];
|
|
583
|
+
|
|
584
|
+
const oid = new ObjectId(id);
|
|
585
|
+
await modelsCollection.updateOne({_id: oid}, {$set: set});
|
|
586
|
+
|
|
587
|
+
modelsCache.del(user.username + '@@' + el.name);
|
|
588
|
+
|
|
589
|
+
const model = await modelsCollection.findOne({_id: oid});
|
|
590
|
+
triggerWorkflows(model, user, 'ModelEdited').catch(workflowError => {
|
|
591
|
+
logger.error(`Erreur asynchrone lors du déclenchement des workflows pour ${model._model} ID ${model._id}:`, workflowError);
|
|
592
|
+
});
|
|
593
|
+
|
|
594
|
+
const newModel = await modelsCollection.findOne({_id: oid});
|
|
595
|
+
const res = ({success: true, data: newModel});
|
|
596
|
+
const plugin = await Event.Trigger("OnModelEdited", "event", "system", engine, newModel);
|
|
597
|
+
await Event.Trigger("OnModelEdited", "event", "user", plugin?.data || newModel);
|
|
598
|
+
return plugin || res
|
|
599
|
+
} catch (e) {
|
|
600
|
+
logger.error(e);
|
|
601
|
+
return ({success: false, error: e.message, statusCode: 500});
|
|
602
|
+
}
|
|
603
|
+
};
|
|
604
|
+
export const createModel = async (data) => {
|
|
605
|
+
return await getCollection('models').insertOne(data);
|
|
606
|
+
}
|
|
607
|
+
export const deleteModels = async (filter) => {
|
|
608
|
+
return await getCollection('models').deleteMany(filter ? filter : {_user: {$exists: false}});
|
|
609
|
+
}
|
|
610
|
+
export const getModel = async (modelName, user) => {
|
|
611
|
+
const modelInCache = modelsCache.get((user?.username || '') + "@@" + modelName);
|
|
612
|
+
if (modelInCache)
|
|
613
|
+
return modelInCache;
|
|
614
|
+
const model = await getCollection('models').findOne({
|
|
615
|
+
name: modelName,
|
|
616
|
+
$and: user ? [{_user: {$exists: true}}, {$or: [{_user: user._user}, {_user: user.username}]}] : [{_user: {$exists: false}}]
|
|
617
|
+
});
|
|
618
|
+
if (!model) {
|
|
619
|
+
throw new Error(i18n.t('api.model.notFound', {model: modelName}));
|
|
620
|
+
}
|
|
621
|
+
modelsCache.set((user?.username || '') + "@@" + modelName, model);
|
|
622
|
+
return model;
|
|
623
|
+
}
|
|
624
|
+
export const getModels = async () => {
|
|
625
|
+
return await getCollection('models')?.find({'$or': [{_user: {$exists: false}}]}).toArray() || [];
|
|
626
|
+
}
|
|
627
|
+
export const insertData = async (modelName, data, files, user, triggerWorkflow = true, waitForWorkflow = true) => {
|
|
628
|
+
|
|
629
|
+
// --- Vérification des permissions (inchangée) ---
|
|
630
|
+
if (!(isDemoUser(user) && Config.Get("useDemoAccounts")) && isLocalUser(user) && (
|
|
631
|
+
!await hasPermission(["API_ADMIN", "API_ADD_DATA", "API_ADD_DATA_" + modelName], user) ||
|
|
632
|
+
await hasPermission(["API_ADD_DATA_NOT_" + modelName], user))) {
|
|
633
|
+
// Renvoyer une structure d'erreur cohérente
|
|
634
|
+
return {success: false, error: i18n.t('api.permission.addData'), statusCode: 403};
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
const collection = await getCollectionForUser(user);
|
|
638
|
+
let insertedIds = []; // Pour stocker les IDs retourn par pushDataUnsecure
|
|
639
|
+
|
|
640
|
+
try {
|
|
641
|
+
// --- Insertion via pushDataUnsecure (inchangée) ---
|
|
642
|
+
insertedIds = await pushDataUnsecure(data, modelName, user, files);
|
|
643
|
+
|
|
644
|
+
// Check if pushDataUnsecure actually returned IDs
|
|
645
|
+
if (!insertedIds || insertedIds.length === 0) {
|
|
646
|
+
logger.warn(`[insertData] pushDataUnsecure did not return inserted IDs for model ${modelName}.`);
|
|
647
|
+
// Retourner échec si l'insertion n'a pas retourné d'IDs
|
|
648
|
+
return {
|
|
649
|
+
success: false,
|
|
650
|
+
unmodified: true,
|
|
651
|
+
error: "Insertion failed, no IDs returned by core function.",
|
|
652
|
+
statusCode: 500
|
|
653
|
+
};
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
// Convertir les IDs en ObjectId pour la recherche
|
|
657
|
+
const objectIds = insertedIds.map(id => new ObjectId(id));
|
|
658
|
+
const insertedDocs = await collection.find({_id: {$in: objectIds}}).toArray();
|
|
659
|
+
|
|
660
|
+
if (!insertedDocs || insertedDocs.length === 0) {
|
|
661
|
+
logger.warn(`[insertData] Could not fetch inserted documents after pushDataUnsecure (IDs: ${insertedIds.join(', ')}).`);
|
|
662
|
+
// Continuer même si on ne peut pas fetch, l'insertion a réussi.
|
|
663
|
+
} else {
|
|
664
|
+
// --- Logique de post-insertion (Workflows et Planification) ---
|
|
665
|
+
const postInsertionPromises = insertedDocs.map(async (doc) => {
|
|
666
|
+
// 1. Déclencher les workflows 'DataAdded' (si activé)
|
|
667
|
+
if (triggerWorkflow) {
|
|
668
|
+
const prom = triggerWorkflows(doc, user, 'DataAdded').then(e => {
|
|
669
|
+
logger.debug(`[insertData] Triggered DataAdded workflow for ${doc._model} ID ${doc._id}.`);
|
|
670
|
+
}).catch(e => {
|
|
671
|
+
logger.error(`[insertData] Error triggering DataAdded workflow for ${doc._model} ID ${doc._id}:`, e);
|
|
672
|
+
});
|
|
673
|
+
if (waitForWorkflow) {
|
|
674
|
+
await prom;
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
if (doc._model === 'workflowTrigger' && doc.isActive === true && doc.cronExpression) {
|
|
679
|
+
const jobId = `workflowTrigger_${doc._id}`;
|
|
680
|
+
logger.info(`[insertData] Scheduling new active workflowTrigger ${doc._id} (${doc.name || 'No Name'}) with cron: "${doc.cronExpression}"`);
|
|
681
|
+
|
|
682
|
+
if (schedule.scheduledJobs[jobId]) {
|
|
683
|
+
logger.warn(`[insertData] Job ${jobId} already exists. Cancelling before rescheduling.`);
|
|
684
|
+
schedule.scheduledJobs[jobId].cancel();
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
try {
|
|
688
|
+
schedule.scheduleJob(jobId, doc.cronExpression, async () => {
|
|
689
|
+
logger.info(`[Scheduled Job] Cron triggered for job ${jobId}. Attempting to run with lock...`);
|
|
690
|
+
await runScheduledJobWithDbLock(
|
|
691
|
+
jobId,
|
|
692
|
+
async () => {
|
|
693
|
+
// --- NOUVELLE LOGIQUE D'ALERTE ---
|
|
694
|
+
logger.info(`[Scheduled Job] Executing alert logic for workflow ${doc.name} (ID: ${doc._id}) for user ${doc._user}`);
|
|
695
|
+
|
|
696
|
+
const alertPayload = {
|
|
697
|
+
type: 'cron_alert',
|
|
698
|
+
triggerId: doc._id.toString(),
|
|
699
|
+
triggerName: doc.name,
|
|
700
|
+
timestamp: new Date().toISOString(),
|
|
701
|
+
message: `L'alerte planifiée '${doc.name || 'Sans nom'}' a été déclenchée.`
|
|
702
|
+
};
|
|
703
|
+
|
|
704
|
+
// Envoyer l'alerte à l'utilisateur spécifique via SSE
|
|
705
|
+
const sent = await sendSseToUser(doc._user, alertPayload);
|
|
706
|
+
|
|
707
|
+
if (sent) {
|
|
708
|
+
logger.info(`[Scheduled Job] Successfully sent SSE alert for job ${jobId} to user ${doc._user}.`);
|
|
709
|
+
} else {
|
|
710
|
+
logger.warn(`[Scheduled Job] Could not send SSE alert for job ${jobId}. User ${doc._user} is not connected via SSE.`);
|
|
711
|
+
}
|
|
712
|
+
// --- FIN DE LA LOGIQUE D'ALERTE ---
|
|
713
|
+
},
|
|
714
|
+
doc.lockDurationMinutes || 5
|
|
715
|
+
);
|
|
716
|
+
});
|
|
717
|
+
logger.info(`[insertData] Successfully scheduled job ${jobId}.`);
|
|
718
|
+
} catch (scheduleError) {
|
|
719
|
+
logger.error(`[insertData] Failed to schedule job ${jobId} for workflowTrigger ${doc._id}. Error: ${scheduleError.message}`);
|
|
720
|
+
}
|
|
721
|
+
} else if (doc._model === 'alert' && doc.isActive === true && doc.frequency) {
|
|
722
|
+
const jobId = `alert_${doc._id}`;
|
|
723
|
+
logger.info(`[insertData] Scheduling new active alert ${doc._id} (${doc.name}) with frequency: "${doc.frequency}"`);
|
|
724
|
+
|
|
725
|
+
if (schedule.scheduledJobs[jobId]) {
|
|
726
|
+
logger.warn(`[insertData] Job ${jobId} already exists. Cancelling before rescheduling.`);
|
|
727
|
+
schedule.scheduledJobs[jobId].cancel();
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
try {
|
|
731
|
+
// --- MODIFICATION ICI ---
|
|
732
|
+
schedule.scheduleJob(jobId, doc.frequency, () => runStatefulAlertJob(doc._id));
|
|
733
|
+
// --- FIN MODIFICATION ---
|
|
734
|
+
logger.info(`[insertData] Successfully scheduled alert job ${jobId}.`);
|
|
735
|
+
} catch (scheduleError) {
|
|
736
|
+
logger.error(`[insertData] Failed to schedule alert job ${jobId}. Error: ${scheduleError.message}`);
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
});
|
|
741
|
+
|
|
742
|
+
// Attendre que toutes les opérations post-insertion (workflows, planification) soient tentées
|
|
743
|
+
await Promise.allSettled(postInsertionPromises);
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
// System specific event
|
|
747
|
+
const eventPayload = {modelName, insertedIds, user};
|
|
748
|
+
await Event.Trigger("OnDataAdded", "event", "system", engine, eventPayload);
|
|
749
|
+
|
|
750
|
+
// User specific event
|
|
751
|
+
const userPayload = {...eventPayload};
|
|
752
|
+
delete userPayload['user'];
|
|
753
|
+
await Event.Trigger("OnDataAdded", "event", "user", userPayload);
|
|
754
|
+
|
|
755
|
+
// Return valid result
|
|
756
|
+
return {success: true, insertedIds: insertedIds.map(id => id.toString())}; // Convertir les IDs en string pour la réponse
|
|
757
|
+
|
|
758
|
+
} catch (error) { // Attrape les erreurs de permission ou de pushDataUnsecure
|
|
759
|
+
logger.error(`[insertData] Main error during insertion process for model ${modelName}: ${error.message}`, error.stack);
|
|
760
|
+
// Renvoyer une structure d'erreur cohérente
|
|
761
|
+
return {
|
|
762
|
+
success: false,
|
|
763
|
+
unmodified: error.unmodified,
|
|
764
|
+
error: error.message || "Insertion failed due to an unexpected error.",
|
|
765
|
+
statusCode: error.statusCode || 500
|
|
766
|
+
};
|
|
767
|
+
}
|
|
768
|
+
};
|
|
769
|
+
/**
|
|
770
|
+
* Fonction principale pour l'insertion de données avec gestion des relations
|
|
771
|
+
* @param {Array<object>|object} data - Données à insérer
|
|
772
|
+
* @param {string} modelName - Nom du modèle cible
|
|
773
|
+
* @param {object} me - Utilisateur courant
|
|
774
|
+
* @param {object} [files={}] - Fichiers associés (optionnel)
|
|
775
|
+
* @returns {Promise<Array<string>>} IDs des documents insérés/trouvés
|
|
776
|
+
*/
|
|
777
|
+
export const pushDataUnsecure = async (data, modelName, me, files = {}) => {
|
|
778
|
+
try {
|
|
779
|
+
// 1. Initialisation et validation
|
|
780
|
+
const {datas, model, collection} = await initializeAndValidate(data, modelName, me);
|
|
781
|
+
if (datas.length === 0) {
|
|
782
|
+
return [];
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
// 2. Vérification des limites (en parallèle avec les contraintes)
|
|
786
|
+
const [_, violations] = await Promise.all([
|
|
787
|
+
checkLimits(datas, model, collection, me),
|
|
788
|
+
checkCompositeUniqueConstraints(datas, model, collection, me)
|
|
789
|
+
]);
|
|
790
|
+
|
|
791
|
+
if (violations.length > 0) {
|
|
792
|
+
throw new Error(`Violation of unique constraints :\n${violations.join('\n')}`);
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
// 3. Traitement des documents
|
|
796
|
+
const {allInsertedIds, idMap} = await processDocuments(datas, model, collection, me);
|
|
797
|
+
|
|
798
|
+
// 4. Gestion des fichiers (optionnel)
|
|
799
|
+
await handleFilesIfNeeded(allInsertedIds, files, model, collection);
|
|
800
|
+
|
|
801
|
+
return allInsertedIds;
|
|
802
|
+
} catch (e) {
|
|
803
|
+
throw e;
|
|
804
|
+
}
|
|
805
|
+
};
|
|
806
|
+
|
|
807
|
+
async function checkCompositeUniqueConstraints(datas, model, collection, user) {
|
|
808
|
+
if (!model.constraints?.length) return [];
|
|
809
|
+
|
|
810
|
+
const uniqueConstraints = model.constraints.filter(c => c.type === 'unique' && c.keys?.length);
|
|
811
|
+
if (!uniqueConstraints.length) return [];
|
|
812
|
+
|
|
813
|
+
// Préparation des vérifications
|
|
814
|
+
const violations = [];
|
|
815
|
+
const userId = user._user || user.username;
|
|
816
|
+
|
|
817
|
+
// Paralléliser par contrainte
|
|
818
|
+
await Promise.all(uniqueConstraints.map(async (constraint) => {
|
|
819
|
+
// Vérifier les champs de la contrainte
|
|
820
|
+
const invalidFields = constraint.keys.filter(key =>
|
|
821
|
+
!model.fields.some(f => f.name === key)
|
|
822
|
+
);
|
|
823
|
+
|
|
824
|
+
if (invalidFields.length) {
|
|
825
|
+
violations.push(`Fields used in constraint [${invalidFields.join(', ')}] '${constraint.name}' are inexistant.`);
|
|
826
|
+
return;
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
// Batch processing des documents (500 max)
|
|
830
|
+
const batchSize = 50;
|
|
831
|
+
for (let i = 0; i < datas.length; i += batchSize) {
|
|
832
|
+
const batch = datas.slice(i, i + batchSize);
|
|
833
|
+
|
|
834
|
+
// Créer toutes les requêtes pour ce batch
|
|
835
|
+
const queries = batch.flatMap(doc => {
|
|
836
|
+
const compositeKey = {};
|
|
837
|
+
let hasNull = false;
|
|
838
|
+
|
|
839
|
+
for (const key of constraint.keys) {
|
|
840
|
+
if (doc[key] == null) {
|
|
841
|
+
hasNull = true;
|
|
842
|
+
break;
|
|
843
|
+
}
|
|
844
|
+
compositeKey[key] = doc[key];
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
return hasNull ? [] : [{
|
|
848
|
+
collection,
|
|
849
|
+
query: {
|
|
850
|
+
...compositeKey,
|
|
851
|
+
_model: model.name,
|
|
852
|
+
_user: userId
|
|
853
|
+
}
|
|
854
|
+
}];
|
|
855
|
+
});
|
|
856
|
+
|
|
857
|
+
// Exécution en parallèle avec $or pour réduire les appels
|
|
858
|
+
if (queries.length) {
|
|
859
|
+
const matchingDocs = await collection.find({
|
|
860
|
+
$or: queries.map(q => q.query)
|
|
861
|
+
}).toArray();
|
|
862
|
+
|
|
863
|
+
if (matchingDocs.length) {
|
|
864
|
+
// Créer un Set des clés existantes pour recherche rapide
|
|
865
|
+
const existingKeys = new Set(
|
|
866
|
+
matchingDocs.map(doc =>
|
|
867
|
+
constraint.keys.map(k => doc[k]).join('|')
|
|
868
|
+
)
|
|
869
|
+
);
|
|
870
|
+
|
|
871
|
+
// Vérifier chaque document du batch
|
|
872
|
+
batch.forEach(doc => {
|
|
873
|
+
const keyValues = constraint.keys.map(k => doc[k]);
|
|
874
|
+
if (keyValues.every(v => v != null)) {
|
|
875
|
+
const compositeKey = keyValues.join('|');
|
|
876
|
+
if (existingKeys.has(compositeKey)) {
|
|
877
|
+
violations.push(
|
|
878
|
+
`[${constraint.name}] Existing data : ${constraint.keys.map((k, i) => `${k}=${keyValues[i]}`).join(', ')}`
|
|
879
|
+
);
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
});
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
}));
|
|
887
|
+
|
|
888
|
+
return violations;
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
/**
|
|
892
|
+
* Initialise et valide les paramètres d'entrée
|
|
893
|
+
*/
|
|
894
|
+
async function initializeAndValidate(data, modelName, me) {
|
|
895
|
+
const datas = normalizeInputData(data);
|
|
896
|
+
if (datas.length === 0) return {datas: [], model: null, collection: null};
|
|
897
|
+
|
|
898
|
+
const model = await getModel(modelName, me);
|
|
899
|
+
const collection = await getCollectionForUser(me);
|
|
900
|
+
await validateModelStructure(model);
|
|
901
|
+
|
|
902
|
+
return {datas, model, collection};
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
/**
|
|
906
|
+
* Normalise les données d'entrée (tableau ou objet unique)
|
|
907
|
+
*/
|
|
908
|
+
function normalizeInputData(data) {
|
|
909
|
+
if (Array.isArray(data)) return data;
|
|
910
|
+
if (isPlainObject(data)) return [data];
|
|
911
|
+
return [];
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
/**
|
|
915
|
+
* Vérifie toutes les limites (stockage, capacité, etc.)
|
|
916
|
+
*/
|
|
917
|
+
async function checkLimits(datas, model, collection, me) {
|
|
918
|
+
const incomingDataSize = calculateDataSize(datas);
|
|
919
|
+
const userStorageLimit = await engine.userProvider.getUserStorageLimit(me);
|
|
920
|
+
|
|
921
|
+
// Vérification des limites utilisateur
|
|
922
|
+
const currentStorageUsage = await calculateTotalUserStorageUsage(me);
|
|
923
|
+
if (currentStorageUsage + incomingDataSize > userStorageLimit) {
|
|
924
|
+
throw new Error(i18n.t("api.data.storageLimitExceeded", {
|
|
925
|
+
limit: Math.round(userStorageLimit / megabytes)
|
|
926
|
+
}));
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
// Vérification capacité serveur
|
|
930
|
+
const serverCapacity = await checkServerCapacity(incomingDataSize);
|
|
931
|
+
if (!serverCapacity.isSufficient) {
|
|
932
|
+
throw new Error(i18n.t("api.data.serverStorageFull"));
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
// Vérification nombre max de documents
|
|
936
|
+
const count = await collection.countDocuments({_user: me._user || me.username});
|
|
937
|
+
if (count + datas.length > maxTotalDataPerUser) {
|
|
938
|
+
throw new Error(i18n.t("api.data.tooManyData"));
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
if (datas.length > maxPostData) {
|
|
942
|
+
throw new Error(i18n.t('api.data.tooManyData'));
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
/**
|
|
947
|
+
* Calcule la taille des données (en octets)
|
|
948
|
+
*/
|
|
949
|
+
function calculateDataSize(datas) {
|
|
950
|
+
try {
|
|
951
|
+
return BSON.calculateObjectSize(datas);
|
|
952
|
+
} catch (e) {
|
|
953
|
+
logger.warn("[Storage] Fallback to JSON.stringify for size estimation.");
|
|
954
|
+
return JSON.stringify(datas).length;
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
export const patchData = async (modelName, filter, data, files, user, triggerWorkflow = true, waitForWorkflow = false) => {
|
|
959
|
+
return await internalEditOrPatchData(modelName, filter, data, files, user, true, triggerWorkflow, waitForWorkflow);
|
|
960
|
+
};
|
|
961
|
+
export const editData = async (modelName, filter, data, files, user, triggerWorkflow = true, waitForWorkflow = false) => {
|
|
962
|
+
return await internalEditOrPatchData(modelName, filter, data, files, user, false, triggerWorkflow, waitForWorkflow);
|
|
963
|
+
};
|
|
964
|
+
const internalEditOrPatchData = async (modelName, filter, data, files, user, isPatch, triggerWorkflow = true, waitForWorkflow = false) => {
|
|
965
|
+
try {
|
|
966
|
+
// 1. Vérification des permissions
|
|
967
|
+
if (user.username !== 'demo' && isLocalUser(user) && (
|
|
968
|
+
!await hasPermission(["API_ADMIN", "API_EDIT_DATA", "API_EDIT_DATA_" + modelName], user) ||
|
|
969
|
+
await hasPermission(["API_EDIT_DATA_NOT_" + modelName], user))) {
|
|
970
|
+
throw new Error(i18n.t("api.permission.editData"));
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
const collection = await getCollectionForUser(user);
|
|
974
|
+
const model = await modelsCollection.findOne({name: modelName, _user: user.username});
|
|
975
|
+
if (!model) {
|
|
976
|
+
throw new Error(i18n.t("api.model.notFound", {model: modelName}));
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
// 2. Récupération des documents existants et de leur hash original
|
|
980
|
+
const existingDocs = (await searchData({model: modelName, filter}, user))?.data;
|
|
981
|
+
if (!existingDocs || existingDocs.length === 0) {
|
|
982
|
+
return {success: false, error: i18n.t("api.data.notFound")};
|
|
983
|
+
}
|
|
984
|
+
const ids = existingDocs.map(d => new ObjectId(d._id));
|
|
985
|
+
const originalHash = existingDocs[0]._hash; // Sauvegarde du hash avant modification
|
|
986
|
+
|
|
987
|
+
// 3. Préparation des données de mise à jour (inchangé)
|
|
988
|
+
const updateData = {...data};
|
|
989
|
+
delete updateData._model;
|
|
990
|
+
delete updateData._user;
|
|
991
|
+
|
|
992
|
+
// Traitement des fichiers (inchangé)
|
|
993
|
+
const fileFields = model.fields.filter(f => f.type === 'file' || (f.type === 'array' && f.itemsType === 'file'));
|
|
994
|
+
for (const field of fileFields) {
|
|
995
|
+
if (files?.[field.name + '[0]']) {
|
|
996
|
+
if (field.type === 'file') {
|
|
997
|
+
updateData[field.name] = await addFile(files[field.name + '[0]'][0], user);
|
|
998
|
+
} else if (field.type === 'array' && field.itemsType === 'file') {
|
|
999
|
+
const currentFiles = existingDocs[0]?.[field.name] || [];
|
|
1000
|
+
const newFiles = await processFileArray(files[field.name + '[0]'], currentFiles, user);
|
|
1001
|
+
updateData[field.name] = newFiles;
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
// 4. Validation adaptée pour patch ou edit (inchangé)
|
|
1007
|
+
if (!isPatch) {
|
|
1008
|
+
const dataToValidate = {...existingDocs[0], ...updateData};
|
|
1009
|
+
await validateModelData(dataToValidate, model, false);
|
|
1010
|
+
} else {
|
|
1011
|
+
await validateModelData(updateData, model, true);
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
// 5. Vérification des champs uniques (inchangé)
|
|
1015
|
+
const uniqueFields = model.fields.filter(f => f.unique);
|
|
1016
|
+
for (const field of uniqueFields) {
|
|
1017
|
+
if (updateData[field.name] !== undefined) {
|
|
1018
|
+
const existing = await collection.findOne({
|
|
1019
|
+
_user: user._user || user.username,
|
|
1020
|
+
_model: modelName,
|
|
1021
|
+
[field.name]: updateData[field.name],
|
|
1022
|
+
_id: {$nin: ids}
|
|
1023
|
+
});
|
|
1024
|
+
if (existing) {
|
|
1025
|
+
throw new Error(i18n.t("api.data.duplicateValue", {
|
|
1026
|
+
field: field.name,
|
|
1027
|
+
value: updateData[field.name]
|
|
1028
|
+
}));
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
// 6. Traitement des relations (inchangé)
|
|
1034
|
+
const relationFields = model.fields.filter(f => f.type === 'relation');
|
|
1035
|
+
for (const field of relationFields) {
|
|
1036
|
+
if (updateData[field.name] !== undefined) {
|
|
1037
|
+
const relationValue = updateData[field.name];
|
|
1038
|
+
// Only process relations if the value is an object or an array containing at least one object.
|
|
1039
|
+
// An array of strings (ObjectIDs) should be passed through as-is for the update.
|
|
1040
|
+
let shouldProcessRelation = false;
|
|
1041
|
+
if (Array.isArray(relationValue)) {
|
|
1042
|
+
// If any item in the array is a plain object, we need to process the whole array
|
|
1043
|
+
// to handle potential nested creations or lookups.
|
|
1044
|
+
if (relationValue.some(item => isPlainObject(item))) {
|
|
1045
|
+
shouldProcessRelation = true;
|
|
1046
|
+
}
|
|
1047
|
+
} else if (isPlainObject(relationValue)) {
|
|
1048
|
+
shouldProcessRelation = true;
|
|
1049
|
+
}
|
|
1050
|
+
if (shouldProcessRelation) {
|
|
1051
|
+
const insertedIds = await pushDataUnsecure(relationValue, field.relation, user);
|
|
1052
|
+
updateData[field.name] = field.multiple ? insertedIds || [] : (insertedIds?.[0] || null);
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
// 7. Application des filtres de champ (ex: hashage de mot de passe) (inchangé)
|
|
1058
|
+
for (const field of model.fields) {
|
|
1059
|
+
if (updateData[field.name] !== undefined && dataTypes[field.type]?.filter) {
|
|
1060
|
+
updateData[field.name] = await dataTypes[field.type].filter(
|
|
1061
|
+
field.type === 'file' ? null : updateData[field.name],
|
|
1062
|
+
field
|
|
1063
|
+
);
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
for (const field of model.fields) {
|
|
1068
|
+
if (field.type === 'relation' && field.relationFilter && updateData[field.name]) {
|
|
1069
|
+
|
|
1070
|
+
const relatedIds = Array.isArray(updateData[field.name])
|
|
1071
|
+
? updateData[field.name]
|
|
1072
|
+
: [updateData[field.name]];
|
|
1073
|
+
|
|
1074
|
+
// Préparer un filtre global : match si _id dans relatedIds ET respecte relationFilter
|
|
1075
|
+
const validationQuery = {
|
|
1076
|
+
$and: [
|
|
1077
|
+
{$in: ['$_id', relatedIds.map(id => ({$toObjectId: id}))]},
|
|
1078
|
+
field.relationFilter
|
|
1079
|
+
]
|
|
1080
|
+
};
|
|
1081
|
+
|
|
1082
|
+
const relatedDocs = await searchData({
|
|
1083
|
+
filter: validationQuery,
|
|
1084
|
+
model: field.relation,
|
|
1085
|
+
limit: relatedIds.length
|
|
1086
|
+
}, user);
|
|
1087
|
+
|
|
1088
|
+
if ((relatedDocs?.count || 0) !== relatedIds.length) {
|
|
1089
|
+
const invalidIds = relatedIds.filter(id =>
|
|
1090
|
+
!relatedDocs.data.some(doc => doc._id.toString() === id.toString())
|
|
1091
|
+
);
|
|
1092
|
+
throw new Error(
|
|
1093
|
+
i18n.t(
|
|
1094
|
+
'api.data.relationFilterFailed',
|
|
1095
|
+
'Les valeurs {{values}} pour le champ {{field}} ne respectent pas le filtre de relation défini.',
|
|
1096
|
+
{field: field.name, values: invalidIds.join(', ')}
|
|
1097
|
+
)
|
|
1098
|
+
);
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
// 8. Calcul du nouveau hash et préparation des données finales
|
|
1104
|
+
const finalStateForHash = {...existingDocs[0], ...updateData};
|
|
1105
|
+
const newHash = getFieldValueHash(model, finalStateForHash);
|
|
1106
|
+
|
|
1107
|
+
const finalDataForSet = {
|
|
1108
|
+
...updateData,
|
|
1109
|
+
_hash: newHash
|
|
1110
|
+
};
|
|
1111
|
+
|
|
1112
|
+
// 9. *** CORRECTION LOGIQUE ***
|
|
1113
|
+
// On ne vérifie l'unicité que si le hash a réellement changé.
|
|
1114
|
+
if (newHash !== originalHash) {
|
|
1115
|
+
const hashCheck = await checkHash(user, model, newHash, existingDocs[0]._id.toString());
|
|
1116
|
+
if (hashCheck) {
|
|
1117
|
+
// Le nouvel état du document créerait un doublon.
|
|
1118
|
+
throw new Error(i18n.t("api.data.notUniqueData"));
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
// 10. Exécution de la mise à jour (inchangé)
|
|
1123
|
+
const bulkOps = [{updateMany: {filter: {_id: {$in: ids}}, update: {$set: finalDataForSet}}}];
|
|
1124
|
+
const bulkResult = await collection.bulkWrite(bulkOps);
|
|
1125
|
+
const modifiedCount = bulkResult.modifiedCount || 0;
|
|
1126
|
+
|
|
1127
|
+
// Déclencher l'événement OnDataEdited avec les états avant/après
|
|
1128
|
+
if (modifiedCount > 0) {
|
|
1129
|
+
const updatedDocs = await collection.find({_id: {$in: ids}}).toArray();
|
|
1130
|
+
await Event.Trigger("OnDataEdited", "event", "system", engine, {
|
|
1131
|
+
modelName,
|
|
1132
|
+
user,
|
|
1133
|
+
before: existingDocs, // Documents avant la modification
|
|
1134
|
+
after: updatedDocs // Documents après la modification
|
|
1135
|
+
});
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
// 11. Tâches post-mise à jour (schedules, workflows) (inchangé)
|
|
1139
|
+
if (["workflowTrigger", "alert"].includes(modelName)) {
|
|
1140
|
+
await handleScheduledJobs(modelName, existingDocs, collection, finalDataForSet);
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
if (triggerWorkflow && modifiedCount > 0) {
|
|
1144
|
+
const updatedDoc = await collection.findOne({_id: ids[0]});
|
|
1145
|
+
if (updatedDoc) {
|
|
1146
|
+
const proms = triggerWorkflows(updatedDoc, user, 'DataEdited')
|
|
1147
|
+
.catch(err => logger.error("[editData] Workflow trigger error:", err));
|
|
1148
|
+
if (waitForWorkflow) {
|
|
1149
|
+
await proms;
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
return {
|
|
1155
|
+
success: true,
|
|
1156
|
+
modifiedCount
|
|
1157
|
+
};
|
|
1158
|
+
|
|
1159
|
+
} catch (error) {
|
|
1160
|
+
logger.error("Erreur lors de la mise à jour de la ressource :", error);
|
|
1161
|
+
return {success: false, error: error.message};
|
|
1162
|
+
}
|
|
1163
|
+
};
|
|
1164
|
+
export const deleteData = async (modelName, filter, user = {}, triggerWorkflow, waitForWorkflow = false) => {
|
|
1165
|
+
|
|
1166
|
+
try {
|
|
1167
|
+
const collection = await getCollectionForUser(user);
|
|
1168
|
+
|
|
1169
|
+
// --- Début de la logique de suppression ---
|
|
1170
|
+
|
|
1171
|
+
// 1. Construire le filtre de base pour trouver les documents à supprimer
|
|
1172
|
+
let findFilter = [];
|
|
1173
|
+
if (user)
|
|
1174
|
+
findFilter.push({
|
|
1175
|
+
'$eq': ["$_user", user.username]
|
|
1176
|
+
});
|
|
1177
|
+
|
|
1178
|
+
// Ajouter le filtre par IDs si fourni
|
|
1179
|
+
if (Array.isArray(filter) && filter.length > 0) {
|
|
1180
|
+
findFilter.push({"$in": ["$_id", filter.map(m => new ObjectId(m))]});
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
// Ajouter le filtre par nom de modèle si fourni (utile si 'filter' est utilisé seul)
|
|
1184
|
+
if (modelName)
|
|
1185
|
+
findFilter.push({
|
|
1186
|
+
'$eq': ["$_model", modelName]
|
|
1187
|
+
});
|
|
1188
|
+
|
|
1189
|
+
// Ajouter le filtre supplémentaire si fourni
|
|
1190
|
+
if (filter && typeof filter === 'object' && Object.keys(filter).length > 0) {
|
|
1191
|
+
// Fusionner prudemment le filtre supplémentaire
|
|
1192
|
+
// Attention: Si 'filter' contient des clés comme _id ou _user,
|
|
1193
|
+
// cela pourrait entrer en conflit. Une fusion plus robuste pourrait re nécessaire.
|
|
1194
|
+
findFilter.push(filter);
|
|
1195
|
+
} else {
|
|
1196
|
+
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
// 2. Récupérer les documents à supprimer pour vérifier leur type et annuler les schedules
|
|
1200
|
+
const documentsToDelete = await collection.aggregate([{$match: {$expr: {"$and": findFilter}}}]).toArray();
|
|
1201
|
+
|
|
1202
|
+
if (documentsToDelete.length === 0) {
|
|
1203
|
+
logger.info(`[deleteData] No documents found matching the criteria for user ${user?.username}.`);
|
|
1204
|
+
return ({success: true, deletedCount: 0, message: "No documents found to delete."});
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
const finalIdsToDelete = []; // IDs des documents qui seront effectivement supprimés
|
|
1208
|
+
|
|
1209
|
+
for (const docToDelete of documentsToDelete) {
|
|
1210
|
+
const deletePromises = [];
|
|
1211
|
+
const model = await getModel(docToDelete._model, user);
|
|
1212
|
+
for (const f of model.fields) {
|
|
1213
|
+
const fieldValue = docToDelete[f.name]; // Valeur du champ actuel depuis le document
|
|
1214
|
+
if (f.type === 'file') {
|
|
1215
|
+
if (typeof fieldValue === 'string' && fieldValue) { // fieldValue est un GUID
|
|
1216
|
+
deletePromises.push(removeFile(fieldValue, user));
|
|
1217
|
+
}
|
|
1218
|
+
} else if (f.type === 'array' && f.itemsType === 'file') {
|
|
1219
|
+
if (Array.isArray(fieldValue)) {
|
|
1220
|
+
for (const guidInArray of fieldValue) {
|
|
1221
|
+
if (typeof guidInArray === 'string' && guidInArray) {
|
|
1222
|
+
deletePromises.push(removeFile(guidInArray, user));
|
|
1223
|
+
}
|
|
1224
|
+
}
|
|
1225
|
+
}
|
|
1226
|
+
}
|
|
1227
|
+
}
|
|
1228
|
+
if (deletePromises.length > 0) {
|
|
1229
|
+
await Promise.allSettled(deletePromises);
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
// Vérification des permissions (pour chaque document trouvé)
|
|
1233
|
+
if (user?.username !== 'demo' && isLocalUser(user) && (
|
|
1234
|
+
!await hasPermission(["API_ADMIN", "API_DELETE_DATA", "API_DELETE_DATA_" + docToDelete._model], user) ||
|
|
1235
|
+
await hasPermission(["API_DELETE_DATA_NOT_" + docToDelete._model], user))) {
|
|
1236
|
+
// Si l'utilisateur n'a pas la permission pour CE document spécifique, on l'ignore
|
|
1237
|
+
logger.warn(`[deleteData] User ${user.username} lacks permission to delete document ${docToDelete._id} of model ${docToDelete._model}. Skipping.`);
|
|
1238
|
+
continue; // Passe au document suivant
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
// *** Ajout de l'annulation du schedule pour workflowTrigger ***
|
|
1242
|
+
if (docToDelete._model === 'workflowTrigger') {
|
|
1243
|
+
const jobId = `workflowTrigger_${docToDelete._id}`;
|
|
1244
|
+
const scheduledJob = schedule.scheduledJobs[jobId];
|
|
1245
|
+
if (scheduledJob) {
|
|
1246
|
+
scheduledJob.cancel();
|
|
1247
|
+
logger.info(`[deleteData] Cancelled scheduled job ${jobId} for deleted workflowTrigger ${docToDelete._id}.`);
|
|
1248
|
+
} else {
|
|
1249
|
+
// Ce n'est pas forcément une erreur si le trigger n'avait pas de cronExpression
|
|
1250
|
+
logger.debug(`[deleteData] No scheduled job found with ID ${jobId} to cancel for workflowTrigger ${docToDelete._id}.`);
|
|
1251
|
+
}
|
|
1252
|
+
} else if (docToDelete._model === 'alert') {
|
|
1253
|
+
const jobId = `alert_${docToDelete._id}`;
|
|
1254
|
+
const scheduledJob = schedule.scheduledJobs[jobId];
|
|
1255
|
+
if (scheduledJob) {
|
|
1256
|
+
scheduledJob.cancel();
|
|
1257
|
+
logger.info(`[deleteData] Cancelled scheduled job ${jobId} for deleted alert ${docToDelete._id}.`);
|
|
1258
|
+
}
|
|
1259
|
+
}
|
|
1260
|
+
// *** Fin de l'ajout ***
|
|
1261
|
+
|
|
1262
|
+
if (user) {
|
|
1263
|
+
// --- Logique existante pour gérer les relations ---
|
|
1264
|
+
const relatedModels = await modelsCollection.aggregate([
|
|
1265
|
+
{
|
|
1266
|
+
$match: {
|
|
1267
|
+
$and: [
|
|
1268
|
+
{"fields.relation": {$eq: docToDelete._model}}, // Utilise le modèle du document actuel
|
|
1269
|
+
{
|
|
1270
|
+
$and: [
|
|
1271
|
+
{_user: {$exists: true}},
|
|
1272
|
+
{
|
|
1273
|
+
$or: [
|
|
1274
|
+
{_user: {$eq: user._user}},
|
|
1275
|
+
{_user: {$eq: user.username}}
|
|
1276
|
+
]
|
|
1277
|
+
}
|
|
1278
|
+
]
|
|
1279
|
+
}
|
|
1280
|
+
]
|
|
1281
|
+
}
|
|
1282
|
+
}
|
|
1283
|
+
]).toArray();
|
|
1284
|
+
|
|
1285
|
+
for (const relatedModel of relatedModels) {
|
|
1286
|
+
let relsSet = {}, pullOps = {}, filterConditions = [];
|
|
1287
|
+
relatedModel.fields.forEach(f => {
|
|
1288
|
+
if (f.type === 'relation' && f.relation === docToDelete._model) {
|
|
1289
|
+
const fieldCondition = {[f.name]: docToDelete._id.toString()};
|
|
1290
|
+
filterConditions.push(fieldCondition); // Condition pour trouver les documents liés
|
|
1291
|
+
|
|
1292
|
+
if (f.multiple) {
|
|
1293
|
+
if (!pullOps.$pull) pullOps.$pull = {};
|
|
1294
|
+
pullOps.$pull[f.name] = docToDelete._id.toString();
|
|
1295
|
+
} else {
|
|
1296
|
+
relsSet[f.name] = null;
|
|
1297
|
+
}
|
|
1298
|
+
}
|
|
1299
|
+
});
|
|
1300
|
+
|
|
1301
|
+
if (filterConditions.length > 0) {
|
|
1302
|
+
const updateFilter = {
|
|
1303
|
+
$and: [
|
|
1304
|
+
{_user: {$exists: true}},
|
|
1305
|
+
{_model: relatedModel.name},
|
|
1306
|
+
{$or: [{_user: user._user}, {_user: user.username}]},
|
|
1307
|
+
{$or: filterConditions}
|
|
1308
|
+
]
|
|
1309
|
+
};
|
|
1310
|
+
|
|
1311
|
+
const updateOps = {};
|
|
1312
|
+
if (Object.keys(relsSet).length > 0) updateOps.$set = relsSet;
|
|
1313
|
+
if (pullOps.$pull && Object.keys(pullOps.$pull).length > 0) updateOps.$pull = pullOps.$pull;
|
|
1314
|
+
|
|
1315
|
+
if (Object.keys(updateOps).length > 0) {
|
|
1316
|
+
const elementsToUpdate = await collection.find(updateFilter).toArray();
|
|
1317
|
+
const updateResult = await collection.updateMany(updateFilter, updateOps);
|
|
1318
|
+
logger.debug(`[deleteData] Updated relations in model ${relatedModel.name} referencing ${docToDelete._id}. Modified: ${updateResult.modifiedCount}`);
|
|
1319
|
+
|
|
1320
|
+
if (triggerWorkflow) {
|
|
1321
|
+
elementsToUpdate.forEach(e => {
|
|
1322
|
+
collection.findOne({_id: e._id}).then(updatedDoc => {
|
|
1323
|
+
if (updatedDoc) {
|
|
1324
|
+
triggerWorkflows(updatedDoc, user, 'DataEdited').catch(workflowError => {
|
|
1325
|
+
logger.error(`[deleteData] Async error triggering DataEdited workflow for ${updatedDoc._model} ID ${updatedDoc._id}:`, workflowError);
|
|
1326
|
+
});
|
|
1327
|
+
}
|
|
1328
|
+
});
|
|
1329
|
+
});
|
|
1330
|
+
}
|
|
1331
|
+
}
|
|
1332
|
+
}
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
// --- Fin de la logique des relations ---
|
|
1336
|
+
|
|
1337
|
+
// Déclencher le workflow DataDeleted AVANT la suppression effective
|
|
1338
|
+
if (triggerWorkflow) {
|
|
1339
|
+
const prom = triggerWorkflows(docToDelete, user, 'DataDeleted').catch(workflowError => {
|
|
1340
|
+
logger.error(`[deleteData] Async error triggering DataDeleted workflow for ${docToDelete._model} ID ${docToDelete._id}:`, workflowError);
|
|
1341
|
+
});
|
|
1342
|
+
|
|
1343
|
+
if (waitForWorkflow) {
|
|
1344
|
+
await prom;
|
|
1345
|
+
}
|
|
1346
|
+
}
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
// Ajouter l'ID à la liste finale si la permission est accordée
|
|
1350
|
+
finalIdsToDelete.push(docToDelete._id);
|
|
1351
|
+
|
|
1352
|
+
} // Fin de la boucle sur documentsToDelete
|
|
1353
|
+
|
|
1354
|
+
// 3. Supprimer effectivement les documents (ceux pour lesquels on a la permission)
|
|
1355
|
+
let deletedCount = 0;
|
|
1356
|
+
if (finalIdsToDelete.length > 0) {
|
|
1357
|
+
const result = await collection.deleteMany({
|
|
1358
|
+
_id: {$in: finalIdsToDelete}
|
|
1359
|
+
// Le filtre _user est déjà implicite car on a fetch les documents de l'utilisateur
|
|
1360
|
+
});
|
|
1361
|
+
deletedCount = result.deletedCount;
|
|
1362
|
+
logger.info(`[deleteData] Successfully deleted ${deletedCount} documents for user ${user?.username}.`);
|
|
1363
|
+
} else {
|
|
1364
|
+
logger.info(`[deleteData] No documents to delete for user ${user?.username} after permission checks or matching criteria.`);
|
|
1365
|
+
}
|
|
1366
|
+
|
|
1367
|
+
const res = {success: true, deletedCount}
|
|
1368
|
+
const plugin = await Event.Trigger("OnDataDeleted", "event", "system", engine, {model: modelName, filter});
|
|
1369
|
+
await Event.Trigger("OnDataDeleted", "event", "user", {model: modelName, filter});
|
|
1370
|
+
return plugin || res;
|
|
1371
|
+
|
|
1372
|
+
} catch (error) {
|
|
1373
|
+
logger.error(`[deleteData] Error during deletion process for user ${user?.username}:`, error);
|
|
1374
|
+
// Renvoyer une structure d'erreur cohérente
|
|
1375
|
+
return ({
|
|
1376
|
+
success: false,
|
|
1377
|
+
error: error.message || "An unexpected error occurred during deletion.",
|
|
1378
|
+
statusCode: error.statusCode || 500
|
|
1379
|
+
});
|
|
1380
|
+
}
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1383
|
+
|
|
1384
|
+
// List of operators that cannot be used inside $expr. $geoNear is handled separately
|
|
1385
|
+
// as it's a full stage, not just an operator.
|
|
1386
|
+
const specialOpKeys = ['$text', '$near', '$nearSphere', '$geoWithin', '$geoIntersects', '$regex'];
|
|
1387
|
+
|
|
1388
|
+
/**
|
|
1389
|
+
* Recursively checks if any part of a filter expression contains a special operator.
|
|
1390
|
+
* @param {*} expression - The filter expression or a part of it.
|
|
1391
|
+
* @returns {boolean}
|
|
1392
|
+
*/
|
|
1393
|
+
const containsSpecialOp = (expression) => {
|
|
1394
|
+
if (Array.isArray(expression)) {
|
|
1395
|
+
return expression.some(item => containsSpecialOp(item));
|
|
1396
|
+
}
|
|
1397
|
+
if (!isPlainObject(expression)) {
|
|
1398
|
+
return false;
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1401
|
+
for (const key in expression) {
|
|
1402
|
+
if (specialOpKeys.includes(key)) {
|
|
1403
|
+
return true;
|
|
1404
|
+
}
|
|
1405
|
+
if (containsSpecialOp(expression[key])) {
|
|
1406
|
+
return true;
|
|
1407
|
+
}
|
|
1408
|
+
}
|
|
1409
|
+
return false;
|
|
1410
|
+
};
|
|
1411
|
+
|
|
1412
|
+
|
|
1413
|
+
export const searchData = async (query, user) => {
|
|
1414
|
+
const {page, limit, sort, model, pipelinesPosition, pipelines: customPipelines = [], ids, timeout, pack} = query;
|
|
1415
|
+
|
|
1416
|
+
if (user && user.username !== 'demo' && isLocalUser(user) && (
|
|
1417
|
+
!await hasPermission(["API_ADMIN", "API_SEARCH_DATA", "API_SEARCH_DATA_" + model], user) ||
|
|
1418
|
+
await hasPermission(["API_SEARCH_DATA_NOT_" + model], user))) {
|
|
1419
|
+
throw new Error(i18n.t('api.permission.searchData'));
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1422
|
+
const collection = await getCollectionForUser(user);
|
|
1423
|
+
const modelElement = await getModel(model, user);
|
|
1424
|
+
|
|
1425
|
+
const allIds = (ids || '').split(",").map(m => m.trim()).filter(Boolean).map(m => {
|
|
1426
|
+
return new ObjectId(m);
|
|
1427
|
+
});
|
|
1428
|
+
let l = Math.min(modelElement.maxRequestData || maxRequestData, limit ? parseInt(limit, 10) : maxRequestData);
|
|
1429
|
+
let p = parseInt(page, 10);
|
|
1430
|
+
let filter = query.filter || {};
|
|
1431
|
+
|
|
1432
|
+
// --- START: Added logic for special query operators ---
|
|
1433
|
+
const specialFilterOps = {};
|
|
1434
|
+
let standardFilter = {};
|
|
1435
|
+
|
|
1436
|
+
// Recursively separate special operators ($text, $nearSphere, etc.) that cannot be used within $expr
|
|
1437
|
+
for (const key in filter) {
|
|
1438
|
+
const value = filter[key];
|
|
1439
|
+
|
|
1440
|
+
// $geoNear is a special case; it's a stage and must be at the top level of the filter.
|
|
1441
|
+
if (key === '$geoNear') {
|
|
1442
|
+
specialFilterOps[key] = value;
|
|
1443
|
+
}
|
|
1444
|
+
// For logical operators, we split their child arrays based on whether they contain special ops.
|
|
1445
|
+
else if ((key === '$and' || key === '$or' || key === '$nor') && Array.isArray(value)) {
|
|
1446
|
+
const hasSpecial = value.some(child => containsSpecialOp(child));
|
|
1447
|
+
|
|
1448
|
+
// If a logical operator contains any condition with a special operator (like $regex or a geo-op),
|
|
1449
|
+
// the entire logical block must be processed in a standard `$match` stage.
|
|
1450
|
+
// This is because splitting the block would break the original logic (e.g., an `$or` would become an `$and`).
|
|
1451
|
+
if (hasSpecial) {
|
|
1452
|
+
specialFilterOps[key] = value;
|
|
1453
|
+
} else {
|
|
1454
|
+
// Otherwise, the entire block is "standard" and can be processed by the $expr-based logic.
|
|
1455
|
+
standardFilter[key] = value;
|
|
1456
|
+
}
|
|
1457
|
+
}
|
|
1458
|
+
// For other keys, check if the expression {key: value} contains a special op.
|
|
1459
|
+
else if (containsSpecialOp({ [key]: value })) {
|
|
1460
|
+
specialFilterOps[key] = value;
|
|
1461
|
+
} else {
|
|
1462
|
+
standardFilter[key] = value;
|
|
1463
|
+
}
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1466
|
+
// The rest of the function will use `standardFilter` for the recursive lookup.
|
|
1467
|
+
filter = standardFilter;
|
|
1468
|
+
// --- END: Added logic for special query operators ---
|
|
1469
|
+
|
|
1470
|
+
let sortObj = null; // Initialize to null
|
|
1471
|
+
if (sort) {
|
|
1472
|
+
sortObj = {};
|
|
1473
|
+
sort.split(',').forEach(s => {
|
|
1474
|
+
const v = s.split(':');
|
|
1475
|
+
sortObj[v[0] || s] = v[1] === 'DESC' ? -1 : 1;
|
|
1476
|
+
});
|
|
1477
|
+
}
|
|
1478
|
+
|
|
1479
|
+
let i = 0;
|
|
1480
|
+
const f = {...filter};
|
|
1481
|
+
|
|
1482
|
+
let depthParam = Math.max(1, Math.min(maxFilterDepth, typeof (query.depth) === 'string' ? parseInt(query.depth) : (typeof (query.depth) === 'number' ? query.depth : 1)));
|
|
1483
|
+
let autoExpand = typeof (query.autoExpand) === 'undefined' || (typeof (query.autoExpand) === 'string' && ['1', 'true'].includes(query.autoExpand.toLowerCase()));
|
|
1484
|
+
|
|
1485
|
+
const recursiveLookup = async (model, data, depth = 1, already = [], parentPath = '') => {
|
|
1486
|
+
|
|
1487
|
+
if (depth > depthParam) {
|
|
1488
|
+
return [];
|
|
1489
|
+
}
|
|
1490
|
+
// Handle null, array, or other non-object data gracefully to prevent crashes.
|
|
1491
|
+
if (!isPlainObject(data)) {
|
|
1492
|
+
return [];
|
|
1493
|
+
}
|
|
1494
|
+
|
|
1495
|
+
let pipelines = [], pipelinesLookups = [];
|
|
1496
|
+
let modelElement;
|
|
1497
|
+
try {
|
|
1498
|
+
modelElement = await getModel(model, user);
|
|
1499
|
+
} catch (e) {
|
|
1500
|
+
return [];
|
|
1501
|
+
}
|
|
1502
|
+
|
|
1503
|
+
let dataRelationF = [], dataNoRelation = {};
|
|
1504
|
+
let dte = changeValue(data, '$find', (name, d, topLevel) => {
|
|
1505
|
+
if (autoExpand)
|
|
1506
|
+
depthParam++;
|
|
1507
|
+
const field = modelElement.fields.find(f => f.name === name);
|
|
1508
|
+
if (!field || !name)
|
|
1509
|
+
return {};
|
|
1510
|
+
if (field.type === "relation") {
|
|
1511
|
+
const dt = {
|
|
1512
|
+
'$ne': [
|
|
1513
|
+
{
|
|
1514
|
+
'$filter': {
|
|
1515
|
+
'input': (depth === 1 ? "$" + name : "$this." + name),
|
|
1516
|
+
'as': 'this',
|
|
1517
|
+
'cond': d
|
|
1518
|
+
}
|
|
1519
|
+
}
|
|
1520
|
+
, []]
|
|
1521
|
+
};
|
|
1522
|
+
dataRelationF.push(dt);
|
|
1523
|
+
dataRelationF.push({"$ne": [(depth === 1 ? "$" + name : "$this." + name), null]})
|
|
1524
|
+
return {"$internal": {}};
|
|
1525
|
+
}
|
|
1526
|
+
dataNoRelation[name] = d;
|
|
1527
|
+
return {"$internal": d};
|
|
1528
|
+
});
|
|
1529
|
+
|
|
1530
|
+
dataNoRelation = changeValue(dte, '$internal', (name, d, topLevel) => {
|
|
1531
|
+
return d;
|
|
1532
|
+
});
|
|
1533
|
+
|
|
1534
|
+
for (let fi of modelElement.fields.sort((s1, s2) => {
|
|
1535
|
+
const v = s1.type === 'relation' ? 1 : 0;
|
|
1536
|
+
const v2 = s2.type === 'relation' ? 1 : 0;
|
|
1537
|
+
|
|
1538
|
+
const t1 = s1.type === 'calculated' ? 1 : 0;
|
|
1539
|
+
const t2 = s2.type === 'calculated' ? 1 : 0;
|
|
1540
|
+
return v <= v2 ? -1 : (t1 <= t2 ? -1 : 1);
|
|
1541
|
+
})) {
|
|
1542
|
+
|
|
1543
|
+
if (already.includes(fi.relation)) {
|
|
1544
|
+
console.warn(`Skipping circular reference to model: ${fi.relation}`);
|
|
1545
|
+
continue;
|
|
1546
|
+
}
|
|
1547
|
+
const relSort = {};
|
|
1548
|
+
if (fi.type === 'relation' && depthParam !== 1) {
|
|
1549
|
+
if (sortObj?.[fi.name]) {
|
|
1550
|
+
|
|
1551
|
+
const sortColumn = await getModel(fi.relation, user);
|
|
1552
|
+
let t = sortColumn.fields.find(f => f.asMain)?.name;
|
|
1553
|
+
if (!t) {
|
|
1554
|
+
let t = sortColumn.fields?.find((f) => f.type === 'string_t')?.name;
|
|
1555
|
+
if (t)
|
|
1556
|
+
relSort['items' + i + '.' + t] = sortObj[fi.name];
|
|
1557
|
+
else {
|
|
1558
|
+
|
|
1559
|
+
t = sortColumn.fields?.find((f) => f.type === 'string')?.name;
|
|
1560
|
+
if (t) {
|
|
1561
|
+
relSort['items' + i + '.' + t] = sortObj[fi.name];
|
|
1562
|
+
}
|
|
1563
|
+
}
|
|
1564
|
+
} else {
|
|
1565
|
+
relSort['items' + i + '.' + t] = sortObj[fi.name];
|
|
1566
|
+
}
|
|
1567
|
+
if (t) {
|
|
1568
|
+
delete sortObj[fi.name];
|
|
1569
|
+
}
|
|
1570
|
+
}
|
|
1571
|
+
|
|
1572
|
+
++i;
|
|
1573
|
+
const lookup = {
|
|
1574
|
+
$lookup: {
|
|
1575
|
+
from: await getUserCollectionName(user),
|
|
1576
|
+
as: "items" + i,
|
|
1577
|
+
let: {
|
|
1578
|
+
dtid: {'$toString': '$_id'}, convertedId: '$' + fi.name
|
|
1579
|
+
},
|
|
1580
|
+
pipeline: [
|
|
1581
|
+
{
|
|
1582
|
+
$match: {
|
|
1583
|
+
$expr: {
|
|
1584
|
+
$and: [
|
|
1585
|
+
...[
|
|
1586
|
+
{$eq: ["$_model", fi.relation]},
|
|
1587
|
+
{$eq: ["$_user", user.username]},
|
|
1588
|
+
fi.multiple ? {
|
|
1589
|
+
$in: [{$toString: "$_id"}, {
|
|
1590
|
+
$map: {
|
|
1591
|
+
input: {$ifNull: ["$$convertedId", []]},
|
|
1592
|
+
as: "relationId",
|
|
1593
|
+
in: {$toString: "$$relationId"}
|
|
1594
|
+
}
|
|
1595
|
+
}]
|
|
1596
|
+
} : {
|
|
1597
|
+
$eq: [
|
|
1598
|
+
{$toString: "$_id"},
|
|
1599
|
+
{$convert: {input: '$$convertedId', to: "string", onError: ''}}
|
|
1600
|
+
]
|
|
1601
|
+
}
|
|
1602
|
+
]
|
|
1603
|
+
]
|
|
1604
|
+
}
|
|
1605
|
+
}
|
|
1606
|
+
},
|
|
1607
|
+
{$limit: maxRelationsPerData}
|
|
1608
|
+
]
|
|
1609
|
+
}
|
|
1610
|
+
};
|
|
1611
|
+
|
|
1612
|
+
pipelinesLookups.push(lookup);
|
|
1613
|
+
pipelinesLookups.push({$limit: Math.floor(maxTotalDataPerUser)});
|
|
1614
|
+
|
|
1615
|
+
const currentPath = parentPath ? `${parentPath}_${fi.name}` : fi.name;
|
|
1616
|
+
fi.path = currentPath;
|
|
1617
|
+
pipelinesLookups.push(
|
|
1618
|
+
{
|
|
1619
|
+
$addFields: {
|
|
1620
|
+
[`${fi.name}`]: (fi.multiple ? "$items" + i : {
|
|
1621
|
+
$cond: {
|
|
1622
|
+
if: {$gt: [{$size: {$ifNull: ["$items" + i, []]}}, 0]},
|
|
1623
|
+
then: "$items" + i,
|
|
1624
|
+
else: null
|
|
1625
|
+
}
|
|
1626
|
+
})
|
|
1627
|
+
}
|
|
1628
|
+
}
|
|
1629
|
+
);
|
|
1630
|
+
|
|
1631
|
+
pipelinesLookups.push(
|
|
1632
|
+
{$project: {['items' + i]: 0}}
|
|
1633
|
+
);
|
|
1634
|
+
|
|
1635
|
+
const nextAlready = [...already, fi.relation];
|
|
1636
|
+
if (Object.keys(relSort).length) {
|
|
1637
|
+
pipelinesLookups.push(
|
|
1638
|
+
{$sort: relSort}
|
|
1639
|
+
);
|
|
1640
|
+
}
|
|
1641
|
+
|
|
1642
|
+
let find = dte[fi.name] || [];
|
|
1643
|
+
|
|
1644
|
+
const rec = await recursiveLookup(fi.relation, find, depth + 1, nextAlready, currentPath);
|
|
1645
|
+
|
|
1646
|
+
if (rec.length) {
|
|
1647
|
+
lookup['$lookup']['pipeline'] = lookup['$lookup']['pipeline'].concat(rec);
|
|
1648
|
+
}
|
|
1649
|
+
|
|
1650
|
+
lookup['$lookup']['pipeline'].push(
|
|
1651
|
+
{$project: {_model: 0}}
|
|
1652
|
+
);
|
|
1653
|
+
lookup['$lookup']['pipeline'].push(
|
|
1654
|
+
{$project: {_user: 0}}
|
|
1655
|
+
);
|
|
1656
|
+
|
|
1657
|
+
} else if (fi.type === 'file') {
|
|
1658
|
+
pipelinesLookups.push({
|
|
1659
|
+
$lookup: {
|
|
1660
|
+
from: "files",
|
|
1661
|
+
let: {fileGuid: '$' + fi.name},
|
|
1662
|
+
pipeline: [
|
|
1663
|
+
{
|
|
1664
|
+
$match: {
|
|
1665
|
+
$expr: {
|
|
1666
|
+
$and: [
|
|
1667
|
+
{$eq: ['$guid', '$$fileGuid']},
|
|
1668
|
+
{$eq: ['$user', user.username]}
|
|
1669
|
+
]
|
|
1670
|
+
}
|
|
1671
|
+
}
|
|
1672
|
+
},
|
|
1673
|
+
{$limit: 1}
|
|
1674
|
+
],
|
|
1675
|
+
as: fi.name + "_details_temp"
|
|
1676
|
+
}
|
|
1677
|
+
});
|
|
1678
|
+
|
|
1679
|
+
pipelinesLookups.push({
|
|
1680
|
+
$addFields: {
|
|
1681
|
+
[fi.name]: {
|
|
1682
|
+
$ifNull: [{$first: '$' + fi.name + "_details_temp"}, null]
|
|
1683
|
+
}
|
|
1684
|
+
}
|
|
1685
|
+
});
|
|
1686
|
+
|
|
1687
|
+
pipelinesLookups.push({
|
|
1688
|
+
$project: {
|
|
1689
|
+
[fi.name + "_details_temp"]: 0
|
|
1690
|
+
}
|
|
1691
|
+
});
|
|
1692
|
+
} else if (fi.type === 'array' && fi.itemsType === 'file' && depthParam !== 1) {
|
|
1693
|
+
pipelinesLookups.push(
|
|
1694
|
+
{
|
|
1695
|
+
$lookup: {
|
|
1696
|
+
from: "files",
|
|
1697
|
+
let: {localGuidsArray: '$' + fi.name},
|
|
1698
|
+
pipeline: [
|
|
1699
|
+
{
|
|
1700
|
+
$match: {
|
|
1701
|
+
$expr: {
|
|
1702
|
+
$in: ['$guid', {$ifNull: ['$$localGuidsArray', []]}]
|
|
1703
|
+
}
|
|
1704
|
+
}
|
|
1705
|
+
}
|
|
1706
|
+
],
|
|
1707
|
+
as: fi.name + "_details_temp"
|
|
1708
|
+
}
|
|
1709
|
+
},
|
|
1710
|
+
{
|
|
1711
|
+
$addFields: {
|
|
1712
|
+
[fi.name]: {
|
|
1713
|
+
$ifNull: [
|
|
1714
|
+
{
|
|
1715
|
+
$map: {
|
|
1716
|
+
input: '$' + fi.name,
|
|
1717
|
+
as: "originalGuidString",
|
|
1718
|
+
in: {
|
|
1719
|
+
$let: {
|
|
1720
|
+
vars: {
|
|
1721
|
+
matchedDetail: {
|
|
1722
|
+
$arrayElemAt: [
|
|
1723
|
+
{
|
|
1724
|
+
$filter: {
|
|
1725
|
+
input: '$' + fi.name + "_details_temp",
|
|
1726
|
+
as: "detailFile",
|
|
1727
|
+
cond: {$eq: ["$$detailFile.guid", "$$originalGuidString"]}
|
|
1728
|
+
}
|
|
1729
|
+
},
|
|
1730
|
+
0
|
|
1731
|
+
]
|
|
1732
|
+
}
|
|
1733
|
+
},
|
|
1734
|
+
in: {
|
|
1735
|
+
$cond: {
|
|
1736
|
+
if: '$$matchedDetail',
|
|
1737
|
+
then: '$$matchedDetail',
|
|
1738
|
+
else: {
|
|
1739
|
+
guid: '$$originalGuidString',
|
|
1740
|
+
name: null,
|
|
1741
|
+
_error: "File details not found"
|
|
1742
|
+
}
|
|
1743
|
+
}
|
|
1744
|
+
}
|
|
1745
|
+
}
|
|
1746
|
+
}
|
|
1747
|
+
}
|
|
1748
|
+
},
|
|
1749
|
+
[]
|
|
1750
|
+
]
|
|
1751
|
+
}
|
|
1752
|
+
}
|
|
1753
|
+
},
|
|
1754
|
+
{
|
|
1755
|
+
$project: {
|
|
1756
|
+
[fi.name + "_details_temp"]: 0
|
|
1757
|
+
}
|
|
1758
|
+
}
|
|
1759
|
+
);
|
|
1760
|
+
} else if (fi.type === 'calculated' && fi.calculation && fi.calculation.pipeline && fi.calculation.final) {
|
|
1761
|
+
const calcPipelineAbstract = fi.calculation.pipeline;
|
|
1762
|
+
const calcFinalFieldName = fi.calculation.final;
|
|
1763
|
+
const tempLookupsForThisCalcField = [];
|
|
1764
|
+
|
|
1765
|
+
if (calcPipelineAbstract.lookups && calcPipelineAbstract.lookups.length > 0) {
|
|
1766
|
+
for (const lookupDef of calcPipelineAbstract.lookups) {
|
|
1767
|
+
if (!lookupDef.localField || typeof lookupDef.localField !== 'string' || lookupDef.localField.trim() === '') {
|
|
1768
|
+
logger.warn(`[Calculated Field Error] ... localField ... invalide ...`);
|
|
1769
|
+
pipelinesLookups.push({$addFields: {[lookupDef.as]: lookupDef.isMultiple ? [] : null}});
|
|
1770
|
+
continue;
|
|
1771
|
+
}
|
|
1772
|
+
|
|
1773
|
+
const targetCollectionName = await getUserCollectionName(user);
|
|
1774
|
+
const localFieldValueInPipeline = `$${lookupDef.localField}`;
|
|
1775
|
+
|
|
1776
|
+
const mongoLookupStage = {
|
|
1777
|
+
$lookup: {
|
|
1778
|
+
from: targetCollectionName,
|
|
1779
|
+
let: {local_val: localFieldValueInPipeline},
|
|
1780
|
+
pipeline: [
|
|
1781
|
+
{
|
|
1782
|
+
$match: {
|
|
1783
|
+
$expr: {
|
|
1784
|
+
$and: [
|
|
1785
|
+
{$eq: ["$_model", lookupDef.foreignModel]},
|
|
1786
|
+
{$eq: ["$_user", user.username]},
|
|
1787
|
+
lookupDef.isMultiple
|
|
1788
|
+
? {$in: [{$toString: "$_id"}, {$ifNull: ["$$local_val", []]}]}
|
|
1789
|
+
: {$eq: [{$toString: "$_id"}, {$ifNull: ["$$local_val", null]}]}
|
|
1790
|
+
]
|
|
1791
|
+
}
|
|
1792
|
+
}
|
|
1793
|
+
}
|
|
1794
|
+
],
|
|
1795
|
+
as: lookupDef.as
|
|
1796
|
+
}
|
|
1797
|
+
};
|
|
1798
|
+
pipelinesLookups.push(mongoLookupStage);
|
|
1799
|
+
tempLookupsForThisCalcField.push(lookupDef.as);
|
|
1800
|
+
|
|
1801
|
+
if (!lookupDef.isMultiple) {
|
|
1802
|
+
pipelinesLookups.push({
|
|
1803
|
+
$unwind: {
|
|
1804
|
+
path: `$${lookupDef.as}`,
|
|
1805
|
+
preserveNullAndEmptyArrays: true
|
|
1806
|
+
}
|
|
1807
|
+
});
|
|
1808
|
+
}
|
|
1809
|
+
}
|
|
1810
|
+
}
|
|
1811
|
+
|
|
1812
|
+
if (calcPipelineAbstract.addFields && Object.keys(calcPipelineAbstract.addFields).length > 0) {
|
|
1813
|
+
const addFields = Object.keys(calcPipelineAbstract.addFields).map(m => ({$addFields: {[m]: calcPipelineAbstract.addFields[m]}}));
|
|
1814
|
+
pipelinesLookups = pipelinesLookups.concat(addFields);
|
|
1815
|
+
}
|
|
1816
|
+
|
|
1817
|
+
if (calcFinalFieldName !== fi.name) {
|
|
1818
|
+
pipelinesLookups.push({$addFields: {[fi.name]: `$${calcFinalFieldName}`}});
|
|
1819
|
+
}
|
|
1820
|
+
|
|
1821
|
+
if (tempLookupsForThisCalcField.length > 0) {
|
|
1822
|
+
const unsetProjection = {};
|
|
1823
|
+
for (const tempField of tempLookupsForThisCalcField) {
|
|
1824
|
+
unsetProjection[tempField] = 0;
|
|
1825
|
+
}
|
|
1826
|
+
if (Object.keys(unsetProjection).length > 0) {
|
|
1827
|
+
pipelinesLookups.push({$project: unsetProjection});
|
|
1828
|
+
}
|
|
1829
|
+
}
|
|
1830
|
+
} else if (fi.type === 'array') {
|
|
1831
|
+
if (data[fi.name]) {
|
|
1832
|
+
pipelines.push({
|
|
1833
|
+
$match: {
|
|
1834
|
+
$expr: {
|
|
1835
|
+
$in: [data[fi.name], '$' + fi.name]
|
|
1836
|
+
}
|
|
1837
|
+
}
|
|
1838
|
+
});
|
|
1839
|
+
}
|
|
1840
|
+
} else if (data[fi.name]) {
|
|
1841
|
+
if (typeof (data[fi.name]) === 'string') {
|
|
1842
|
+
pipelines.push({
|
|
1843
|
+
$match: {
|
|
1844
|
+
$expr: {
|
|
1845
|
+
$eq: ['$' + fi.name, data[fi.name]]
|
|
1846
|
+
}
|
|
1847
|
+
}
|
|
1848
|
+
})
|
|
1849
|
+
}
|
|
1850
|
+
}
|
|
1851
|
+
}
|
|
1852
|
+
|
|
1853
|
+
return pipelines.concat(
|
|
1854
|
+
[
|
|
1855
|
+
{$match: {'_pack': pack ? pack : {$exists: false}}},
|
|
1856
|
+
{$match: {$expr: dataNoRelation}}
|
|
1857
|
+
],
|
|
1858
|
+
customPipelines, // ← INTÉGRATION DES PIPELINES PERSONNALISÉES
|
|
1859
|
+
pipelinesLookups,
|
|
1860
|
+
[{$match: {$expr: {$and: dataRelationF}}}]
|
|
1861
|
+
);
|
|
1862
|
+
};
|
|
1863
|
+
|
|
1864
|
+
let pipelines = [];
|
|
1865
|
+
|
|
1866
|
+
// --- START: Modified pipeline construction for special operators ---
|
|
1867
|
+
if (specialFilterOps.$geoNear && (Object.values(specialFilterOps).some(v => v?.$nearSphere) || specialFilterOps.$text)) {
|
|
1868
|
+
throw new Error("A $geoNear stage cannot be combined with $nearSphere or $text operators in the same query.");
|
|
1869
|
+
}
|
|
1870
|
+
|
|
1871
|
+
// --- Strategy ---
|
|
1872
|
+
// 1. If a $nearSphere operator is found, convert it to a $geoNear stage. This must be the first stage.
|
|
1873
|
+
// Other special filters (like $regex) will be moved into the `query` part of the $geoNear stage.
|
|
1874
|
+
// 2. If a user-provided $geoNear stage exists, use it. It must be the first stage.
|
|
1875
|
+
// 3. If a $text operator is found, it must be in the first $match stage. It is mutually exclusive with geo-queries.
|
|
1876
|
+
|
|
1877
|
+
let nearSphereField = null;
|
|
1878
|
+
let nearSphereKey = null;
|
|
1879
|
+
for (const key in specialFilterOps) {
|
|
1880
|
+
if (isPlainObject(specialFilterOps[key]) && specialFilterOps[key].$nearSphere) {
|
|
1881
|
+
if (nearSphereField) {
|
|
1882
|
+
throw new Error("Query cannot contain multiple $nearSphere operators. Use a single $geoNear stage for complex geo-queries.");
|
|
1883
|
+
}
|
|
1884
|
+
nearSphereField = specialFilterOps[key].$nearSphere;
|
|
1885
|
+
nearSphereKey = key;
|
|
1886
|
+
}
|
|
1887
|
+
}
|
|
1888
|
+
|
|
1889
|
+
if (nearSphereField) {
|
|
1890
|
+
// A $geoNear stage must be the first stage.
|
|
1891
|
+
if (specialFilterOps.$geoNear || specialFilterOps.$text) {
|
|
1892
|
+
throw new Error("Cannot use $nearSphere with a $geoNear stage or a $text operator.");
|
|
1893
|
+
}
|
|
1894
|
+
|
|
1895
|
+
const geoNearStage = {
|
|
1896
|
+
near: nearSphereField.$geometry,
|
|
1897
|
+
distanceField: "distance", // Default distance field name
|
|
1898
|
+
key: nearSphereKey, // The field to perform the search on
|
|
1899
|
+
spherical: true,
|
|
1900
|
+
query: { // Base query for model and user
|
|
1901
|
+
_model: modelElement.name,
|
|
1902
|
+
_user: user.username
|
|
1903
|
+
}
|
|
1904
|
+
};
|
|
1905
|
+
|
|
1906
|
+
// Conditionally add distance fields to avoid passing 'undefined' to MongoDB
|
|
1907
|
+
if (nearSphereField.$maxDistance !== undefined) {
|
|
1908
|
+
geoNearStage.maxDistance = nearSphereField.$maxDistance;
|
|
1909
|
+
}
|
|
1910
|
+
if (nearSphereField.$minDistance !== undefined) {
|
|
1911
|
+
geoNearStage.minDistance = nearSphereField.$minDistance;
|
|
1912
|
+
}
|
|
1913
|
+
|
|
1914
|
+
// Remove the processed nearSphere operator from specialFilterOps
|
|
1915
|
+
delete specialFilterOps[nearSphereKey];
|
|
1916
|
+
|
|
1917
|
+
// Add any other special filters (like $regex) to the $geoNear query
|
|
1918
|
+
Object.assign(geoNearStage.query, specialFilterOps);
|
|
1919
|
+
|
|
1920
|
+
if (allIds.length > 0) {
|
|
1921
|
+
geoNearStage.query._id = { $in: allIds };
|
|
1922
|
+
}
|
|
1923
|
+
|
|
1924
|
+
pipelines.push({ $geoNear: geoNearStage });
|
|
1925
|
+
|
|
1926
|
+
// Clear specialFilterOps as they've all been moved into the $geoNear query
|
|
1927
|
+
Object.keys(specialFilterOps).forEach(key => delete specialFilterOps[key]);
|
|
1928
|
+
|
|
1929
|
+
} else if (specialFilterOps.$geoNear) {
|
|
1930
|
+
// Handle a user-provided $geoNear stage
|
|
1931
|
+
const geoNearStage = { ...specialFilterOps.$geoNear };
|
|
1932
|
+
geoNearStage.query = {
|
|
1933
|
+
...(geoNearStage.query || {}),
|
|
1934
|
+
_model: modelElement.name,
|
|
1935
|
+
_user: user.username
|
|
1936
|
+
};
|
|
1937
|
+
if (allIds.length > 0) {
|
|
1938
|
+
geoNearStage.query._id = { $in: allIds };
|
|
1939
|
+
}
|
|
1940
|
+
pipelines.push({ $geoNear: geoNearStage });
|
|
1941
|
+
delete specialFilterOps.$geoNear;
|
|
1942
|
+
|
|
1943
|
+
} else if (Object.keys(specialFilterOps).length > 0) {
|
|
1944
|
+
// Handle other special operators like $text and $regex if no geo-query was present.
|
|
1945
|
+
const standardMatchQueries = {};
|
|
1946
|
+
if (specialFilterOps.$text) {
|
|
1947
|
+
standardMatchQueries.$text = specialFilterOps.$text;
|
|
1948
|
+
delete specialFilterOps.$text;
|
|
1949
|
+
}
|
|
1950
|
+
Object.assign(standardMatchQueries, specialFilterOps);
|
|
1951
|
+
|
|
1952
|
+
standardMatchQueries._model = modelElement.name;
|
|
1953
|
+
standardMatchQueries._user = user.username;
|
|
1954
|
+
if (allIds.length > 0) {
|
|
1955
|
+
standardMatchQueries._id = { $in: allIds };
|
|
1956
|
+
}
|
|
1957
|
+
pipelines.push({ $match: standardMatchQueries });
|
|
1958
|
+
}
|
|
1959
|
+
|
|
1960
|
+
// Add the original initial match logic, but only if no pipeline stages have been created yet.
|
|
1961
|
+
if (pipelines.length === 0) {
|
|
1962
|
+
if (allIds.length) {
|
|
1963
|
+
// Note: allIds are already ObjectIds from the start of the function.
|
|
1964
|
+
const id = {$in: ["$_id", allIds]};
|
|
1965
|
+
pipelines.push({
|
|
1966
|
+
$match: {$expr: id}
|
|
1967
|
+
});
|
|
1968
|
+
} else {
|
|
1969
|
+
pipelines.push(
|
|
1970
|
+
{
|
|
1971
|
+
$match: {
|
|
1972
|
+
$expr: {
|
|
1973
|
+
$and: [
|
|
1974
|
+
{$eq: ["$_model", modelElement.name]},
|
|
1975
|
+
{$eq: ["$_user", user.username]}
|
|
1976
|
+
]
|
|
1977
|
+
}
|
|
1978
|
+
}
|
|
1979
|
+
}
|
|
1980
|
+
);
|
|
1981
|
+
}
|
|
1982
|
+
}
|
|
1983
|
+
// --- END: Modified pipeline construction ---
|
|
1984
|
+
|
|
1985
|
+
// Intégration des pipelines personnalisés au début si nécessaire
|
|
1986
|
+
if (customPipelines.length > 0 && pipelinesPosition === 'start') {
|
|
1987
|
+
pipelines = pipelines.concat(customPipelines);
|
|
1988
|
+
}
|
|
1989
|
+
|
|
1990
|
+
pipelines = pipelines.concat(await recursiveLookup(model, filter, 1, []));
|
|
1991
|
+
|
|
1992
|
+
// Intégration des pipelines personnalisés à la fin si nécessaire
|
|
1993
|
+
if (customPipelines.length > 0 && pipelinesPosition !== 'start') {
|
|
1994
|
+
pipelines = pipelines.concat(customPipelines);
|
|
1995
|
+
}
|
|
1996
|
+
|
|
1997
|
+
if (depthParam) {
|
|
1998
|
+
pipelines.push({$project: {_user: 0}});
|
|
1999
|
+
pipelines.push({$project: {_model: 0}});
|
|
2000
|
+
}
|
|
2001
|
+
|
|
2002
|
+
const ts = parseInt(timeout, 10) / 2.0 || searchRequestTimeout;
|
|
2003
|
+
const count = await collection.aggregate([...pipelines, {$count: "count"}]).maxTimeMS(ts).toArray();
|
|
2004
|
+
let prom = collection.aggregate(pipelines).maxTimeMS(ts);
|
|
2005
|
+
|
|
2006
|
+
// Apply sort logic:
|
|
2007
|
+
// 1. If a user-defined sort exists, use it.
|
|
2008
|
+
// 2. If not, and if there's no $geoNear stage (which has implicit sort), apply a default sort.
|
|
2009
|
+
if (sortObj) {
|
|
2010
|
+
prom.sort(sortObj);
|
|
2011
|
+
} else if (!pipelines.some(stage => stage.$geoNear)) {
|
|
2012
|
+
const defaultSort = { [modelElement.fields[0]?.name || '_id']: ['datetime', 'date'].includes(modelElement.fields[0].type) ? -1 : 1 };
|
|
2013
|
+
prom.sort(defaultSort);
|
|
2014
|
+
}
|
|
2015
|
+
prom.skip(p ? (p - 1) * l : 0).limit(l);
|
|
2016
|
+
let data = await prom.toArray();
|
|
2017
|
+
data = await handleFields(modelElement, data, user);
|
|
2018
|
+
|
|
2019
|
+
const res = {data, count: count[0]?.count || 0};
|
|
2020
|
+
const plugin = await Event.Trigger("OnDataSearched", "event", "system", engine, {data, count: count[0]?.count});
|
|
2021
|
+
await Event.Trigger("OnDataSearched", "event", "user", plugin || {data, count: count[0]?.count});
|
|
2022
|
+
return plugin || res;
|
|
2023
|
+
}
|
|
2024
|
+
export const importData = async (options, files, user) => {
|
|
2025
|
+
|
|
2026
|
+
if (!(isDemoUser(user) && Config.Get("useDemoAccounts")) && isLocalUser(user) && !await hasPermission(["API_ADMIN", "API_IMPORT_DATA"], user)) {
|
|
2027
|
+
return ({success: false, error: "API_IMPORT_DATA permission needed."});
|
|
2028
|
+
}
|
|
2029
|
+
|
|
2030
|
+
const file = files.file;
|
|
2031
|
+
const hasHeaders = options.hasHeaders ? options.hasHeaders === 'true' : true;
|
|
2032
|
+
const csvHeadersString = options.csvHeaders;
|
|
2033
|
+
|
|
2034
|
+
if (!file) {
|
|
2035
|
+
return ({success: false, error: "No file uploaded."});
|
|
2036
|
+
}
|
|
2037
|
+
|
|
2038
|
+
const importJobId = new ObjectId().toString();
|
|
2039
|
+
importJobs[importJobId] = {
|
|
2040
|
+
userId: user.username,
|
|
2041
|
+
status: 'pending',
|
|
2042
|
+
totalRecords: 0,
|
|
2043
|
+
processedRecords: 0,
|
|
2044
|
+
errors: [],
|
|
2045
|
+
jobId: importJobId // Inclure l'ID de la tâche dans son état
|
|
2046
|
+
};
|
|
2047
|
+
|
|
2048
|
+
const importJob = importJobs[importJobId];
|
|
2049
|
+
// Excuter le reste de la logique d'importation en arrière-plan
|
|
2050
|
+
(async () => {
|
|
2051
|
+
let fileProcessed = false;
|
|
2052
|
+
const importResults = {success: true, counts: {}, errors: []}; // Pour collecter les erreurs internes
|
|
2053
|
+
|
|
2054
|
+
try {
|
|
2055
|
+
const fileContent = fs.readFileSync(file.path);
|
|
2056
|
+
// --- DÉBUT DE LA MODIFICATION ---
|
|
2057
|
+
// La variable allProcessedData est maintenant déclarée à l'intérieur de la boucle
|
|
2058
|
+
// let allProcessedData = [];
|
|
2059
|
+
// let modelNameForImport = '';
|
|
2060
|
+
// --- FIN DE LA MODIFICATION ---
|
|
2061
|
+
if (!file.name) {
|
|
2062
|
+
throw new Error("No file provided.");
|
|
2063
|
+
}
|
|
2064
|
+
if (file.type === 'application/json' || file.name.endsWith('.json')) {
|
|
2065
|
+
fileProcessed = true;
|
|
2066
|
+
const jsonData = await runImportExportWorker('parse-json', {fileContent: fileContent.toString()});
|
|
2067
|
+
|
|
2068
|
+
// --- Logique d'importation des modèles (inchangée) ---
|
|
2069
|
+
if (jsonData.models && Array.isArray(jsonData.models)) {
|
|
2070
|
+
logger.info(`[Model Import] Found ${jsonData.models.length} models to import for user ${user.username}.`);
|
|
2071
|
+
const userModels = await modelsCollection.find({_user: user.username}).toArray();
|
|
2072
|
+
const userModelNames = userModels.map(m => m.name);
|
|
2073
|
+
|
|
2074
|
+
for (const modelToInstall of jsonData.models) {
|
|
2075
|
+
try {
|
|
2076
|
+
const modelName = modelToInstall.name;
|
|
2077
|
+
if (!modelName) {
|
|
2078
|
+
throw new Error("Model definition in import file is missing a 'name'.");
|
|
2079
|
+
}
|
|
2080
|
+
|
|
2081
|
+
if (userModelNames.includes(modelName)) {
|
|
2082
|
+
logger.debug(`[Model Import] Skipping '${modelName}': already exists for user.`);
|
|
2083
|
+
continue;
|
|
2084
|
+
}
|
|
2085
|
+
|
|
2086
|
+
const modelData = {...modelToInstall};
|
|
2087
|
+
delete modelData._id;
|
|
2088
|
+
modelData._user = user.username;
|
|
2089
|
+
modelData.locked = false;
|
|
2090
|
+
if (modelData.fields) {
|
|
2091
|
+
modelData.fields.forEach(f => {
|
|
2092
|
+
delete f._id;
|
|
2093
|
+
f.locked = false;
|
|
2094
|
+
});
|
|
2095
|
+
}
|
|
2096
|
+
|
|
2097
|
+
await validateModelStructure(modelData);
|
|
2098
|
+
await modelsCollection.insertOne(modelData);
|
|
2099
|
+
logger.info(`[Model Import] Successfully imported model '${modelName}' for user ${user.username}.`);
|
|
2100
|
+
} catch (e) {
|
|
2101
|
+
const errorMsg = `Failed to import model '${modelToInstall.name || 'N/A'}': ${e.message}`;
|
|
2102
|
+
logger.error(`[Model Import] ${errorMsg}`);
|
|
2103
|
+
importResults.errors.push(errorMsg);
|
|
2104
|
+
importResults.success = false;
|
|
2105
|
+
}
|
|
2106
|
+
}
|
|
2107
|
+
}
|
|
2108
|
+
|
|
2109
|
+
const dataToProcess = jsonData.data || jsonData;
|
|
2110
|
+
const modelsToProcess = [];
|
|
2111
|
+
|
|
2112
|
+
if (Array.isArray(dataToProcess)) {
|
|
2113
|
+
const modelNameForImport = options.model;
|
|
2114
|
+
if (!modelNameForImport) {
|
|
2115
|
+
importResults.errors.push("Model name is required in the request body when JSON is an array.");
|
|
2116
|
+
importResults.success = false;
|
|
2117
|
+
} else {
|
|
2118
|
+
modelsToProcess.push({name: modelNameForImport, data: dataToProcess});
|
|
2119
|
+
}
|
|
2120
|
+
} else if (typeof dataToProcess === 'object' && dataToProcess !== null) {
|
|
2121
|
+
for (const modelKey in dataToProcess) {
|
|
2122
|
+
if (modelKey === 'models') continue;
|
|
2123
|
+
if (Object.prototype.hasOwnProperty.call(dataToProcess, modelKey)) {
|
|
2124
|
+
if (Array.isArray(dataToProcess[modelKey])) {
|
|
2125
|
+
modelsToProcess.push({name: modelKey, data: dataToProcess[modelKey]});
|
|
2126
|
+
} else {
|
|
2127
|
+
const errorMsg = `Data for model '${modelKey}' in JSON object is not an array. Skipping.`;
|
|
2128
|
+
logger.warn(`[Import JSON Object] ${errorMsg}`);
|
|
2129
|
+
importResults.errors.push(errorMsg);
|
|
2130
|
+
importResults.success = false;
|
|
2131
|
+
}
|
|
2132
|
+
}
|
|
2133
|
+
}
|
|
2134
|
+
} else {
|
|
2135
|
+
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.");
|
|
2136
|
+
importResults.success = false;
|
|
2137
|
+
}
|
|
2138
|
+
|
|
2139
|
+
if (modelsToProcess.length === 0 && importResults.errors.length === 0) {
|
|
2140
|
+
importResults.errors.push("No data found to process in JSON file.");
|
|
2141
|
+
importResults.success = false;
|
|
2142
|
+
}
|
|
2143
|
+
|
|
2144
|
+
// --- DÉBUT DE LA MODIFICATION PRINCIPALE ---
|
|
2145
|
+
// Mettre à jour le statut et le nombre total d'enregistrements avant la boucle
|
|
2146
|
+
if (importResults.success && modelsToProcess.length > 0) {
|
|
2147
|
+
importJobs[importJobId].totalRecords = modelsToProcess.reduce((acc, model) => acc + model.data.length, 0);
|
|
2148
|
+
importJobs[importJobId].status = 'processing';
|
|
2149
|
+
}
|
|
2150
|
+
|
|
2151
|
+
// Boucler sur CHAQUE modèle trouvé dans le fichier JSON
|
|
2152
|
+
for (const modelData of modelsToProcess) {
|
|
2153
|
+
const modelName = modelData.name;
|
|
2154
|
+
const dataArray = modelData.data;
|
|
2155
|
+
|
|
2156
|
+
if (dataArray.length === 0) {
|
|
2157
|
+
logger.info(`[Import Job ${importJobId}] Skipping model '${modelName}' as it has no data.`);
|
|
2158
|
+
continue;
|
|
2159
|
+
}
|
|
2160
|
+
|
|
2161
|
+
logger.info(`[Import Job ${importJobId}] Processing ${dataArray.length} records for model '${modelName}'.`);
|
|
2162
|
+
|
|
2163
|
+
try {
|
|
2164
|
+
const modelDef = await getModel(modelName, user);
|
|
2165
|
+
if (!modelDef) {
|
|
2166
|
+
throw new Error(i18n.t('api.model.notFound', {model: modelName}));
|
|
2167
|
+
}
|
|
2168
|
+
|
|
2169
|
+
const allProcessedData = convertDataTypes(dataArray, modelDef.fields, 'json');
|
|
2170
|
+
|
|
2171
|
+
// Logique de découpage en lots pour le modèle actuel
|
|
2172
|
+
for (let i = 0; i < allProcessedData.length; i += IMPORT_CHUNK_SIZE) {
|
|
2173
|
+
const chunk = allProcessedData.slice(i, i + IMPORT_CHUNK_SIZE);
|
|
2174
|
+
try {
|
|
2175
|
+
const insertedIdsArray = await pushDataUnsecure(chunk, modelName, user, {});
|
|
2176
|
+
if (insertedIdsArray && insertedIdsArray.length > 0) {
|
|
2177
|
+
importJobs[importJobId].processedRecords += insertedIdsArray.length;
|
|
2178
|
+
logger.debug(`[Import Job ${importJobId}] Processed chunk for '${modelName}': ${insertedIdsArray.length} records. Total processed: ${importJobs[importJobId].processedRecords}`);
|
|
2179
|
+
sendSseToUser(user.username, {
|
|
2180
|
+
type: 'import_progress',
|
|
2181
|
+
job: importJobs[importJobId]
|
|
2182
|
+
});
|
|
2183
|
+
}
|
|
2184
|
+
} catch (chunkError) {
|
|
2185
|
+
console.log(chunkError.stack);
|
|
2186
|
+
const errorMsg = `[Import Job ${importJobId}] Error on chunk for model '${modelName}': ${chunkError.message}`;
|
|
2187
|
+
logger.error(errorMsg);
|
|
2188
|
+
importResults.errors.push(errorMsg);
|
|
2189
|
+
importJobs[importJobId].errors.push(errorMsg);
|
|
2190
|
+
importResults.success = false;
|
|
2191
|
+
}
|
|
2192
|
+
|
|
2193
|
+
if (i + IMPORT_CHUNK_SIZE < allProcessedData.length) {
|
|
2194
|
+
await delay(IMPORT_CHUNK_DELAY_MS);
|
|
2195
|
+
}
|
|
2196
|
+
}
|
|
2197
|
+
importResults.counts[modelName] = (importResults.counts[modelName] || 0) + allProcessedData.length;
|
|
2198
|
+
|
|
2199
|
+
} catch (modelProcessingError) {
|
|
2200
|
+
const errorMsg = `[Import Job ${importJobId}] Failed to process model '${modelName}': ${modelProcessingError.message}`;
|
|
2201
|
+
logger.error(errorMsg);
|
|
2202
|
+
importResults.errors.push(errorMsg);
|
|
2203
|
+
importJobs[importJobId].errors.push(errorMsg);
|
|
2204
|
+
importResults.success = false;
|
|
2205
|
+
}
|
|
2206
|
+
}
|
|
2207
|
+
// --- FIN DE LA MODIFICATION PRINCIPALE ---
|
|
2208
|
+
|
|
2209
|
+
} else if (file.type === 'text/csv' || file.name.endsWith('.csv')) {
|
|
2210
|
+
// --- Logique CSV (inchangée, mais maintenant elle est séparée de la logique JSON) ---
|
|
2211
|
+
fileProcessed = true;
|
|
2212
|
+
const modelNameForImport = options.model;
|
|
2213
|
+
if (!modelNameForImport) {
|
|
2214
|
+
importResults.errors.push("Model name is required in the request body for CSV import.");
|
|
2215
|
+
importResults.success = false;
|
|
2216
|
+
} else {
|
|
2217
|
+
try {
|
|
2218
|
+
const modelDef = await getModel(modelNameForImport, user);
|
|
2219
|
+
if (!modelDef) {
|
|
2220
|
+
throw new Error(i18n.t('api.model.notFound', {model: modelNameForImport}));
|
|
2221
|
+
}
|
|
2222
|
+
|
|
2223
|
+
let userDefinedHeadersForMapping = [];
|
|
2224
|
+
if (!hasHeaders && csvHeadersString && typeof csvHeadersString === 'string') {
|
|
2225
|
+
userDefinedHeadersForMapping = csvHeadersString.split(',').map(h => h.trim());
|
|
2226
|
+
}
|
|
2227
|
+
|
|
2228
|
+
const records = await runImportExportWorker('parse-csv', {
|
|
2229
|
+
fileContent: fileContent.toString(),
|
|
2230
|
+
options: {columns: hasHeaders}
|
|
2231
|
+
});
|
|
2232
|
+
|
|
2233
|
+
let datasToImport;
|
|
2234
|
+
if (!hasHeaders) {
|
|
2235
|
+
const effectiveHeadersForMapping = (userDefinedHeadersForMapping.length > 0 && userDefinedHeadersForMapping.some(h => h !== ''))
|
|
2236
|
+
? userDefinedHeadersForMapping
|
|
2237
|
+
: modelDef.fields.map(f => f.name);
|
|
2238
|
+
|
|
2239
|
+
datasToImport = records.map(recordRow => {
|
|
2240
|
+
const obj = {};
|
|
2241
|
+
if (Array.isArray(recordRow)) {
|
|
2242
|
+
recordRow.forEach((value, index) => {
|
|
2243
|
+
const targetModelFieldName = effectiveHeadersForMapping[index];
|
|
2244
|
+
if (targetModelFieldName && targetModelFieldName !== '') {
|
|
2245
|
+
if (modelDef.fields.some(mf => mf.name === targetModelFieldName)) {
|
|
2246
|
+
obj[targetModelFieldName] = value;
|
|
2247
|
+
} else {
|
|
2248
|
+
logger.warn(`CSV Import (!hasHeaders): Specified target field "${targetModelFieldName}" at column ${index + 1} does not exist in model "${modelNameForImport}". Skipping column.`);
|
|
2249
|
+
}
|
|
2250
|
+
}
|
|
2251
|
+
});
|
|
2252
|
+
} else {
|
|
2253
|
+
Object.values(recordRow).forEach((value, index) => {
|
|
2254
|
+
const targetModelFieldName = effectiveHeadersForMapping[index];
|
|
2255
|
+
if (targetModelFieldName && targetModelFieldName !== '') {
|
|
2256
|
+
if (modelDef.fields.some(mf => mf.name === targetModelFieldName)) {
|
|
2257
|
+
obj[targetModelFieldName] = value;
|
|
2258
|
+
} else {
|
|
2259
|
+
logger.warn(`CSV Import (!hasHeaders, object row): Specified target field "${targetModelFieldName}" at column ${index + 1} does not exist in model "${modelNameForImport}". Skipping column.`);
|
|
2260
|
+
}
|
|
2261
|
+
}
|
|
2262
|
+
});
|
|
2263
|
+
}
|
|
2264
|
+
return obj;
|
|
2265
|
+
});
|
|
2266
|
+
} else {
|
|
2267
|
+
datasToImport = records;
|
|
2268
|
+
}
|
|
2269
|
+
const allProcessedData = convertDataTypes(datasToImport, modelDef.fields, 'csv');
|
|
2270
|
+
|
|
2271
|
+
// Logique de découpage en lots pour le CSV
|
|
2272
|
+
if (allProcessedData.length > 0) {
|
|
2273
|
+
importJobs[importJobId].totalRecords = allProcessedData.length;
|
|
2274
|
+
importJobs[importJobId].status = 'processing';
|
|
2275
|
+
|
|
2276
|
+
for (let i = 0; i < allProcessedData.length; i += IMPORT_CHUNK_SIZE) {
|
|
2277
|
+
const chunk = allProcessedData.slice(i, i + IMPORT_CHUNK_SIZE);
|
|
2278
|
+
try {
|
|
2279
|
+
const insertedIdsArray = await pushDataUnsecure(chunk, modelNameForImport, user, {});
|
|
2280
|
+
if (insertedIdsArray && insertedIdsArray.length > 0) {
|
|
2281
|
+
importJobs[importJobId].processedRecords += insertedIdsArray.length;
|
|
2282
|
+
sendSseToUser(user.username, {
|
|
2283
|
+
type: 'import_progress',
|
|
2284
|
+
job: importJobs[importJobId]
|
|
2285
|
+
});
|
|
2286
|
+
}
|
|
2287
|
+
} catch (chunkError) {
|
|
2288
|
+
const errorMsg = `[Import Job ${importJobId}] Error on CSV chunk: ${chunkError.message}`;
|
|
2289
|
+
logger.error(errorMsg, chunkError.stack);
|
|
2290
|
+
importResults.errors.push(errorMsg);
|
|
2291
|
+
importJobs[importJobId].errors.push(errorMsg);
|
|
2292
|
+
importResults.success = false;
|
|
2293
|
+
}
|
|
2294
|
+
if (i + IMPORT_CHUNK_SIZE < allProcessedData.length) {
|
|
2295
|
+
await delay(IMPORT_CHUNK_DELAY_MS);
|
|
2296
|
+
}
|
|
2297
|
+
}
|
|
2298
|
+
importResults.counts[modelNameForImport] = (importResults.counts[modelNameForImport] || 0) + allProcessedData.length;
|
|
2299
|
+
}
|
|
2300
|
+
|
|
2301
|
+
} catch (modelProcessingError) {
|
|
2302
|
+
logger.error(`[Import CSV] Error processing model ${modelNameForImport}: ${modelProcessingError.message}`);
|
|
2303
|
+
importResults.errors.push(`Model ${modelNameForImport} (CSV): ${modelProcessingError.message}`);
|
|
2304
|
+
importResults.success = false;
|
|
2305
|
+
importJobs[importJobId].errors.push(`Model ${modelNameForImport} (CSV): ${modelProcessingError.message}`);
|
|
2306
|
+
}
|
|
2307
|
+
}
|
|
2308
|
+
} else if (['application/vnd.ms-excel', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'].includes(file.type) || file.name.endsWith('.xls') || file.name.endsWith('.xlsx')) {
|
|
2309
|
+
fileProcessed = true;
|
|
2310
|
+
const modelNameForImport = options.model;
|
|
2311
|
+
if (!modelNameForImport) {
|
|
2312
|
+
importResults.errors.push("Model name is required in the request body for Excel import.");
|
|
2313
|
+
importResults.success = false;
|
|
2314
|
+
} else {
|
|
2315
|
+
try {
|
|
2316
|
+
const modelDef = await getModel(modelNameForImport, user);
|
|
2317
|
+
if (!modelDef) {
|
|
2318
|
+
throw new Error(i18n.t('api.model.notFound', {model: modelNameForImport}));
|
|
2319
|
+
}
|
|
2320
|
+
|
|
2321
|
+
let datasToImport;
|
|
2322
|
+
let excelErrors = [];
|
|
2323
|
+
|
|
2324
|
+
// Cas 2: Pas d'en-têtes. On lit les lignes brutes et on les mappe.
|
|
2325
|
+
const rows = await readXlsxFile(fileContent, {sheet: 1});
|
|
2326
|
+
|
|
2327
|
+
let userDefinedHeadersForMapping = [];
|
|
2328
|
+
if (csvHeadersString && typeof csvHeadersString === 'string') {
|
|
2329
|
+
userDefinedHeadersForMapping = csvHeadersString.split(',').map(h => h.trim());
|
|
2330
|
+
}
|
|
2331
|
+
|
|
2332
|
+
const effectiveHeadersForMapping = (userDefinedHeadersForMapping.length > 0 && userDefinedHeadersForMapping.some(h => h !== ''))
|
|
2333
|
+
? userDefinedHeadersForMapping
|
|
2334
|
+
: modelDef.fields.map(f => f.name);
|
|
2335
|
+
|
|
2336
|
+
datasToImport = rows.map(recordRow => {
|
|
2337
|
+
const obj = {};
|
|
2338
|
+
if (Array.isArray(recordRow)) {
|
|
2339
|
+
recordRow.forEach((value, index) => {
|
|
2340
|
+
const targetModelFieldName = effectiveHeadersForMapping[index];
|
|
2341
|
+
if (targetModelFieldName && targetModelFieldName !== '') {
|
|
2342
|
+
if (modelDef.fields.some(mf => mf.name === targetModelFieldName)) {
|
|
2343
|
+
obj[targetModelFieldName] = value;
|
|
2344
|
+
} else {
|
|
2345
|
+
logger.warn(`Excel Import (!hasHeaders): Specified target field "${targetModelFieldName}" at column ${index + 1} does not exist in model "${modelNameForImport}". Skipping column.`);
|
|
2346
|
+
}
|
|
2347
|
+
}
|
|
2348
|
+
});
|
|
2349
|
+
}
|
|
2350
|
+
return obj;
|
|
2351
|
+
});
|
|
2352
|
+
|
|
2353
|
+
if (excelErrors.length > 0) {
|
|
2354
|
+
excelErrors.forEach(error => {
|
|
2355
|
+
const errorMsg = `Excel Import Error (Row ${error.row}, Column "${error.column}"): ${error.error}.`;
|
|
2356
|
+
logger.error(`[Import Job ${importJobId}] ${errorMsg}`);
|
|
2357
|
+
importResults.errors.push(errorMsg);
|
|
2358
|
+
importJobs[importJobId].errors.push(errorMsg);
|
|
2359
|
+
});
|
|
2360
|
+
importResults.success = false;
|
|
2361
|
+
}
|
|
2362
|
+
|
|
2363
|
+
if (datasToImport && datasToImport.length > 0) {
|
|
2364
|
+
const allProcessedData = convertDataTypes(datasToImport, modelDef.fields, 'excel');
|
|
2365
|
+
|
|
2366
|
+
importJobs[importJobId].totalRecords = allProcessedData.length;
|
|
2367
|
+
importJobs[importJobId].status = 'processing';
|
|
2368
|
+
|
|
2369
|
+
for (let i = 0; i < allProcessedData.length; i += IMPORT_CHUNK_SIZE) {
|
|
2370
|
+
const chunk = allProcessedData.slice(i, i + IMPORT_CHUNK_SIZE);
|
|
2371
|
+
try {
|
|
2372
|
+
const insertedIdsArray = await pushDataUnsecure(chunk, modelNameForImport, user, {});
|
|
2373
|
+
if (insertedIdsArray && insertedIdsArray.length > 0) {
|
|
2374
|
+
importJobs[importJobId].processedRecords += insertedIdsArray.length;
|
|
2375
|
+
sendSseToUser(user.username, {
|
|
2376
|
+
type: 'import_progress',
|
|
2377
|
+
job: importJobs[importJobId]
|
|
2378
|
+
});
|
|
2379
|
+
}
|
|
2380
|
+
} catch (chunkError) {
|
|
2381
|
+
const errorMsg = `[Import Job ${importJobId}] Error on Excel chunk: ${chunkError.message}`;
|
|
2382
|
+
logger.error(errorMsg, chunkError.stack);
|
|
2383
|
+
importResults.errors.push(errorMsg);
|
|
2384
|
+
importJobs[importJobId].errors.push(errorMsg);
|
|
2385
|
+
importResults.success = false;
|
|
2386
|
+
}
|
|
2387
|
+
if (i + IMPORT_CHUNK_SIZE < allProcessedData.length) await delay(IMPORT_CHUNK_DELAY_MS);
|
|
2388
|
+
}
|
|
2389
|
+
importResults.counts[modelNameForImport] = (importResults.counts[modelNameForImport] || 0) + allProcessedData.length;
|
|
2390
|
+
}
|
|
2391
|
+
|
|
2392
|
+
} catch (modelProcessingError) {
|
|
2393
|
+
logger.error(`[Import Excel] Error processing model ${modelNameForImport}: ${modelProcessingError.message}`);
|
|
2394
|
+
importResults.errors.push(`Model ${modelNameForImport} (Excel): ${modelProcessingError.message}`);
|
|
2395
|
+
importResults.success = false;
|
|
2396
|
+
importJobs[importJobId].errors.push(`Model ${modelNameForImport} (Excel): ${modelProcessingError.message}`);
|
|
2397
|
+
}
|
|
2398
|
+
}
|
|
2399
|
+
} else {
|
|
2400
|
+
importResults.errors.push("Unsupported file type. Please upload a JSON or CSV file.");
|
|
2401
|
+
importResults.success = false;
|
|
2402
|
+
importJobs[importJobId].errors.push("Unsupported file type. Please upload a JSON or CSV file.");
|
|
2403
|
+
}
|
|
2404
|
+
|
|
2405
|
+
} catch (e) {
|
|
2406
|
+
logger.error("Import Error (Global):", e);
|
|
2407
|
+
importResults.success = false;
|
|
2408
|
+
importResults.errors.push(e.message || "An unexpected error occurred during import.");
|
|
2409
|
+
if (importJobs[importJobId]) {
|
|
2410
|
+
importJobs[importJobId].errors.push(e.message || "An unexpected error occurred during import.");
|
|
2411
|
+
}
|
|
2412
|
+
} finally {
|
|
2413
|
+
if (importJobs[importJobId]) {
|
|
2414
|
+
if (importResults.errors.length > 0) {
|
|
2415
|
+
importJobs[importJobId].status = 'failed';
|
|
2416
|
+
} else {
|
|
2417
|
+
importJobs[importJobId].status = 'completed';
|
|
2418
|
+
}
|
|
2419
|
+
sendSseToUser(user.username, {
|
|
2420
|
+
type: 'import_progress',
|
|
2421
|
+
job: importJobs[importJobId]
|
|
2422
|
+
});
|
|
2423
|
+
}
|
|
2424
|
+
if (file && file.path && fs.existsSync(file.path)) {
|
|
2425
|
+
fs.unlinkSync(file.path);
|
|
2426
|
+
}
|
|
2427
|
+
}
|
|
2428
|
+
})().catch(error => {
|
|
2429
|
+
logger.error(`Unhandled error in background import job ${importJobId}:`, error);
|
|
2430
|
+
if (importJobs[importJobId]) {
|
|
2431
|
+
importJobs[importJobId].status = 'failed';
|
|
2432
|
+
importJobs[importJobId].errors.push(error.message || "An unhandled error occurred in background process.");
|
|
2433
|
+
sendSseToUser(user.username, {
|
|
2434
|
+
type: 'import_progress',
|
|
2435
|
+
job: importJobs[importJobId]
|
|
2436
|
+
});
|
|
2437
|
+
}
|
|
2438
|
+
});
|
|
2439
|
+
return ({success: true, message: "Import initiated. Check progress via SSE.", job: importJob});
|
|
2440
|
+
}
|
|
2441
|
+
export const exportData = async (options, user) => {
|
|
2442
|
+
// Extract parameters from request body and query
|
|
2443
|
+
const {models, ids, filter = {}, depth, lang} = options;
|
|
2444
|
+
const userId = getUserId(user);
|
|
2445
|
+
|
|
2446
|
+
const effectiveMaxDepth = maxExportCount ?? maxFilterDepth; // Use defined constant or fallback
|
|
2447
|
+
i18n.changeLanguage(lang);
|
|
2448
|
+
|
|
2449
|
+
// --- Input Validation ---
|
|
2450
|
+
if (!Array.isArray(models) || models.length === 0) {
|
|
2451
|
+
return {success: false, error: i18n.t('api.export.error.noModels', 'Models array is required.')};
|
|
2452
|
+
}
|
|
2453
|
+
const parsedDepth = parseInt(depth, 10);
|
|
2454
|
+
if (isNaN(parsedDepth) || parsedDepth < 0 || parsedDepth > effectiveMaxDepth) {
|
|
2455
|
+
return {
|
|
2456
|
+
success: false,
|
|
2457
|
+
error: i18n.t('api.export.error.invalidDepth', `Invalid depth parameter. Must be between 0 and ${effectiveMaxDepth}.`, {maxDepth: effectiveMaxDepth})
|
|
2458
|
+
};
|
|
2459
|
+
}
|
|
2460
|
+
if (ids && !Array.isArray(ids)) {
|
|
2461
|
+
return {
|
|
2462
|
+
success: false,
|
|
2463
|
+
error: i18n.t('api.export.error.invalidIdsType', 'ids parameter must be an array if provided.')
|
|
2464
|
+
};
|
|
2465
|
+
}
|
|
2466
|
+
if (ids && !ids.every(id => typeof id === 'string' || isObjectId(id))) { // Allow string or ObjectId format
|
|
2467
|
+
return {
|
|
2468
|
+
success: false,
|
|
2469
|
+
error: i18n.t('api.export.error.invalidIdsContent', 'ids parameter must contain valid identifiers (strings or ObjectIds).')
|
|
2470
|
+
};
|
|
2471
|
+
}
|
|
2472
|
+
|
|
2473
|
+
const exportResults = {};
|
|
2474
|
+
let totalDocsFetched = 0;
|
|
2475
|
+
const errors = [];
|
|
2476
|
+
|
|
2477
|
+
let modelsToExport = [];
|
|
2478
|
+
|
|
2479
|
+
// --- Permissions & Data Fetching Loop ---
|
|
2480
|
+
for (const modelName of models) {
|
|
2481
|
+
if (totalDocsFetched >= maxExportCount) {
|
|
2482
|
+
console.warn(`Export limit of ${maxExportCount} documents reached before processing model ${modelName}.`);
|
|
2483
|
+
errors.push(i18n.t('api.export.error.limitReached', 'Export document limit reached.', {limit: maxExportCount}));
|
|
2484
|
+
break; // Stop fetching more models if limit is hit early
|
|
2485
|
+
}
|
|
2486
|
+
|
|
2487
|
+
try {
|
|
2488
|
+
// Check permission to search/export data for this model
|
|
2489
|
+
// Using API_SEARCH_DATA as a proxy, adjust if a specific export permission exists
|
|
2490
|
+
// Assuming checkPermission is adapted or hasPermission is used directly
|
|
2491
|
+
// Note: Your original code used checkPermission, but data.js has hasPermission. Adjust as needed.
|
|
2492
|
+
// Example using hasPermission:
|
|
2493
|
+
// if (user.username !== 'demo' && isLocalUser(user) && !await hasPermission(["API_ADMIN", "API_SEARCH_DATA", "API_SEARCH_DATA_"+modelName], user)) {
|
|
2494
|
+
// Example using checkPermission (if it exists and works similarly):
|
|
2495
|
+
if (isLocalUser(user) && !(isDemoUser(user) && Config.Get("useDemoAccounts")) && !(await hasPermission('API_EXPORT_DATA', user))) { // Adapt this line based on your actual permission function
|
|
2496
|
+
console.warn(`User ${userId} lacks permission to search/export model ${modelName}`);
|
|
2497
|
+
errors.push(i18n.t('api.permission.searchData', 'Cannot search data from the API') + ` (${modelName})`);
|
|
2498
|
+
continue; // Skip this model
|
|
2499
|
+
}
|
|
2500
|
+
|
|
2501
|
+
// Fetch model definition securely (needed by searchData internally anyway)
|
|
2502
|
+
// getModel might throw if not found, handle this
|
|
2503
|
+
try {
|
|
2504
|
+
const mod = await getModel(modelName, user);
|
|
2505
|
+
modelsToExport.push(mod);
|
|
2506
|
+
} catch (modelError) {
|
|
2507
|
+
console.warn(`Model ${modelName} not found or not accessible for user ${userId}: ${modelError.message}`);
|
|
2508
|
+
errors.push(i18n.t('api.model.notFound', 'Model {{model}} not found.', {model: modelName}));
|
|
2509
|
+
continue; // Skip this model
|
|
2510
|
+
}
|
|
2511
|
+
|
|
2512
|
+
|
|
2513
|
+
// Construct the query filter for the current model
|
|
2514
|
+
let modelSpecificFilter = {...(filter[modelName] || {})}; // Use model-specific filter from request if provided
|
|
2515
|
+
// _user filter is handled internally by searchData based on the user object passed
|
|
2516
|
+
|
|
2517
|
+
if (ids && ids.length > 0)
|
|
2518
|
+
modelSpecificFilter = {$in: ['$_id', ids]};
|
|
2519
|
+
|
|
2520
|
+
// Calculate remaining limit for this model
|
|
2521
|
+
const remainingLimit = maxExportCount - totalDocsFetched;
|
|
2522
|
+
|
|
2523
|
+
// --- Fetch Data using searchData ---
|
|
2524
|
+
const searchParams = {
|
|
2525
|
+
model: modelName,
|
|
2526
|
+
filter: modelSpecificFilter,
|
|
2527
|
+
depth: parsedDepth,
|
|
2528
|
+
limit: remainingLimit
|
|
2529
|
+
};
|
|
2530
|
+
|
|
2531
|
+
const {data: resultData, count} = await searchData(searchParams, user);
|
|
2532
|
+
|
|
2533
|
+
if (resultData && resultData.length > 0) {
|
|
2534
|
+
exportResults[modelName] = resultData;
|
|
2535
|
+
totalDocsFetched += resultData.length;
|
|
2536
|
+
logger.debug(`Fetched ${resultData.length} documents for model ${modelName}. Total: ${totalDocsFetched}`);
|
|
2537
|
+
} else {
|
|
2538
|
+
logger.debug(`No documents found for model ${modelName} with the given criteria.`);
|
|
2539
|
+
}
|
|
2540
|
+
|
|
2541
|
+
} catch (modelError) {
|
|
2542
|
+
// Catch errors specific to processing this model (e.g., from searchData)
|
|
2543
|
+
console.error(`Error exporting model ${modelName}:`, modelError);
|
|
2544
|
+
errors.push(i18n.t('api.export.error.modelError', 'Error exporting model {{model}}.', {model: modelName}) + ` (${modelError.message})`);
|
|
2545
|
+
}
|
|
2546
|
+
} // End of loop through models
|
|
2547
|
+
|
|
2548
|
+
// --- Prepare and Send Response ---
|
|
2549
|
+
if (Object.keys(exportResults).length === 0) {
|
|
2550
|
+
const finalError = errors.length > 0 ? errors.join('; ') : i18n.t('api.data.noExportData', 'No data found for the specified criteria or permissions denied.');
|
|
2551
|
+
// Use 404 if no data found/accessible, 400 if only errors occurred but no data attempt was possible
|
|
2552
|
+
const statusCode = errors.length > 0 && totalDocsFetched === 0 ? 400 : 404;
|
|
2553
|
+
return {success: false, error: finalError};
|
|
2554
|
+
}
|
|
2555
|
+
|
|
2556
|
+
// Include errors in the response if any occurred but some data was fetched
|
|
2557
|
+
if (errors.length > 0) {
|
|
2558
|
+
exportResults._exportErrors = errors;
|
|
2559
|
+
}
|
|
2560
|
+
|
|
2561
|
+
const res = {success: true, data: exportResults, models: modelsToExport};
|
|
2562
|
+
const plugin = await Event.Trigger("OnDataExported", "event", "system", engine, exportResults, modelsToExport);
|
|
2563
|
+
await Event.Trigger("OnDataExported", "event", "user", plugin?.exportResults || exportResults, plugin?.modelsToExport || modelsToExport);
|
|
2564
|
+
return plugin || res;
|
|
2565
|
+
}
|
|
2566
|
+
|
|
2567
|
+
/**
|
|
2568
|
+
* Installs pack models and data for a user or globally.
|
|
2569
|
+
* Can accept either a pack ID (to install from database) or a direct pack JSON object.
|
|
2570
|
+
*
|
|
2571
|
+
* @param {object} logger - Logger instance
|
|
2572
|
+
* @param {string|object} packIdentifier - Either pack ID (string) or pack JSON object
|
|
2573
|
+
* @param {object|null} user - User object (if installing for user) or null (for global install)
|
|
2574
|
+
* @param {string} [lang='en'] - Language code for localized data
|
|
2575
|
+
* @returns {Promise<{success: boolean, summary: object, errors: Array, modifiedCount: number}>}
|
|
2576
|
+
*/
|
|
2577
|
+
export async function installPack(packIdentifier, user = null, lang = 'en', options = {}) {
|
|
2578
|
+
let pack;
|
|
2579
|
+
const packsCollection = getCollection('packs');
|
|
2580
|
+
|
|
2581
|
+
// Determine if we're working with an ID or direct pack object
|
|
2582
|
+
if (typeof packIdentifier === 'string') {
|
|
2583
|
+
let p;
|
|
2584
|
+
try {
|
|
2585
|
+
p = new ObjectId(packIdentifier);
|
|
2586
|
+
} catch (e) {
|
|
2587
|
+
p = packIdentifier;
|
|
2588
|
+
}
|
|
2589
|
+
// Existing behavior - fetch from database
|
|
2590
|
+
pack = await packsCollection.findOne({$and: [{_user: {$exists: false}}, {private: false}, {$or: [{_id: p}, {name: packIdentifier}]}]});
|
|
2591
|
+
if (!pack) {
|
|
2592
|
+
throw new Error(`Pack with ID ${packIdentifier} not found.`);
|
|
2593
|
+
}
|
|
2594
|
+
} else if (typeof packIdentifier === 'object' && packIdentifier !== null) {
|
|
2595
|
+
// New behavior - use provided pack object directly
|
|
2596
|
+
pack = packIdentifier;
|
|
2597
|
+
|
|
2598
|
+
// Validate basic pack structure
|
|
2599
|
+
if (!pack.name || (!pack.models && !pack.data)) {
|
|
2600
|
+
throw new Error('Invalid pack structure - must contain at least name and models or data');
|
|
2601
|
+
}
|
|
2602
|
+
} else {
|
|
2603
|
+
throw new Error('Invalid pack identifier - must be either pack ID string or pack object');
|
|
2604
|
+
}
|
|
2605
|
+
|
|
2606
|
+
const username = user ? user.username : null;
|
|
2607
|
+
const logPrefix = username
|
|
2608
|
+
? `Installing pack '${pack.name}' for user '${username}'`
|
|
2609
|
+
: `Installing pack '${pack.name}' globally`;
|
|
2610
|
+
|
|
2611
|
+
logger.info(`--- ${logPrefix} ---`);
|
|
2612
|
+
|
|
2613
|
+
const summary = {
|
|
2614
|
+
models: {installed: [], skipped: [], failed: []},
|
|
2615
|
+
datas: {inserted: 0, updated: 0, skipped: 0, failed: 0}
|
|
2616
|
+
};
|
|
2617
|
+
const errors = [];
|
|
2618
|
+
const collection = user ? await getCollectionForUser(user) : getCollection('data');
|
|
2619
|
+
const tempIdToNewIdMap = {};
|
|
2620
|
+
const linkCache = new Map();
|
|
2621
|
+
|
|
2622
|
+
// --- PHASE 1: MODEL INSTALLATION ---
|
|
2623
|
+
if (Array.isArray(pack.models)) {
|
|
2624
|
+
// For user installs, check existing models
|
|
2625
|
+
const existingModels = user
|
|
2626
|
+
? await modelsCollection.find({_user: username}).toArray()
|
|
2627
|
+
: await modelsCollection.find({_user: {$exists: false}}).toArray();
|
|
2628
|
+
|
|
2629
|
+
console.log("EXISTING", existingModels);
|
|
2630
|
+
|
|
2631
|
+
const existingModelNames = existingModels.map(m => m.name);
|
|
2632
|
+
|
|
2633
|
+
for (const modelOrName of pack.models) {
|
|
2634
|
+
try {
|
|
2635
|
+
const modelName = typeof modelOrName === 'string' ? modelOrName : modelOrName?.name;
|
|
2636
|
+
if (!modelName) throw new Error('Model definition in pack is missing a name.');
|
|
2637
|
+
|
|
2638
|
+
if (existingModelNames.includes(modelName)) {
|
|
2639
|
+
logger.debug(`[Model Install] Skipping '${modelName}': already exists`);
|
|
2640
|
+
summary.models.skipped.push(modelName);
|
|
2641
|
+
continue;
|
|
2642
|
+
}
|
|
2643
|
+
|
|
2644
|
+
const modelToInstall = typeof modelOrName === 'string'
|
|
2645
|
+
? await modelsCollection.findOne({name: modelName, _user: {$exists: false}})
|
|
2646
|
+
: {...modelOrName};
|
|
2647
|
+
|
|
2648
|
+
if (!modelToInstall) {
|
|
2649
|
+
throw new Error(`Model '${modelName}' not found in shared models`);
|
|
2650
|
+
}
|
|
2651
|
+
|
|
2652
|
+
// Prepare model for installation
|
|
2653
|
+
const preparedModel = {...modelToInstall};
|
|
2654
|
+
if (user) preparedModel._user = username;
|
|
2655
|
+
delete preparedModel._id;
|
|
2656
|
+
preparedModel.locked = false;
|
|
2657
|
+
|
|
2658
|
+
if (preparedModel.fields) {
|
|
2659
|
+
preparedModel.fields.forEach(f => f.locked = false);
|
|
2660
|
+
}
|
|
2661
|
+
|
|
2662
|
+
await validateModelStructure(preparedModel);
|
|
2663
|
+
await modelsCollection.insertOne(preparedModel);
|
|
2664
|
+
summary.models.installed.push(modelName);
|
|
2665
|
+
|
|
2666
|
+
} catch (e) {
|
|
2667
|
+
const modelName = typeof modelOrName === 'string' ? modelOrName : modelOrName?.name || 'unknown';
|
|
2668
|
+
errors.push(`Failed to install model '${modelName}': ${e.message}`);
|
|
2669
|
+
summary.models.failed.push(modelName);
|
|
2670
|
+
}
|
|
2671
|
+
}
|
|
2672
|
+
}
|
|
2673
|
+
|
|
2674
|
+
// --- PHASE 2: DATA INSTALLATION ---
|
|
2675
|
+
const dataToInstall = {...pack.data?.all, ...pack.data?.[lang]};
|
|
2676
|
+
if (!dataToInstall || Object.keys(dataToInstall).length === 0) {
|
|
2677
|
+
logger.warn(`Pack '${pack.name}' has no data to install.`);
|
|
2678
|
+
return {success: false, summary, errors, modifiedCount: 0};
|
|
2679
|
+
}
|
|
2680
|
+
|
|
2681
|
+
// Process link references (same as original)
|
|
2682
|
+
const linkQueue = [];
|
|
2683
|
+
for (const modelName in dataToInstall) {
|
|
2684
|
+
if (Array.isArray(dataToInstall[modelName])) {
|
|
2685
|
+
for (const docSource of dataToInstall[modelName]) {
|
|
2686
|
+
const tempId = new ObjectId().toString();
|
|
2687
|
+
docSource._temp_pack_id = tempId;
|
|
2688
|
+
|
|
2689
|
+
for (const fieldName in docSource) {
|
|
2690
|
+
if (isPlainObject(docSource[fieldName]) && docSource[fieldName].$link) {
|
|
2691
|
+
linkQueue.push({
|
|
2692
|
+
sourceTempId: tempId,
|
|
2693
|
+
sourceModelName: modelName,
|
|
2694
|
+
fieldName,
|
|
2695
|
+
linkSelector: docSource[fieldName].$link
|
|
2696
|
+
});
|
|
2697
|
+
}
|
|
2698
|
+
}
|
|
2699
|
+
}
|
|
2700
|
+
}
|
|
2701
|
+
}
|
|
2702
|
+
|
|
2703
|
+
// --- PASS 1: BATCH INSERTION ---
|
|
2704
|
+
logger.info("[Pack Install] Starting Pass 1: Batch Insertion & ID Mapping");
|
|
2705
|
+
for (const modelName in dataToInstall) {
|
|
2706
|
+
if (!Array.isArray(dataToInstall[modelName])) continue;
|
|
2707
|
+
|
|
2708
|
+
const documents = dataToInstall[modelName];
|
|
2709
|
+
if (documents.length === 0) continue;
|
|
2710
|
+
|
|
2711
|
+
const docsToInsert = [];
|
|
2712
|
+
const modelDefForHash = await getModel(modelName, user);
|
|
2713
|
+
|
|
2714
|
+
for (const docSource of documents) {
|
|
2715
|
+
let docForInsert = {...docSource};
|
|
2716
|
+
|
|
2717
|
+
// Clear $link fields for first pass
|
|
2718
|
+
for (const key in docForInsert) {
|
|
2719
|
+
if (isPlainObject(docForInsert[key]) && docForInsert[key].$link) {
|
|
2720
|
+
docForInsert[key] = null;
|
|
2721
|
+
}
|
|
2722
|
+
}
|
|
2723
|
+
|
|
2724
|
+
const tempId = docForInsert._temp_pack_id;
|
|
2725
|
+
delete docForInsert._id;
|
|
2726
|
+
delete docForInsert._temp_pack_id;
|
|
2727
|
+
|
|
2728
|
+
if (user) docForInsert._user = username;
|
|
2729
|
+
docForInsert._model = modelName;
|
|
2730
|
+
docForInsert._hash = getFieldValueHash(modelDefForHash, docForInsert);
|
|
2731
|
+
|
|
2732
|
+
// Check for existing document
|
|
2733
|
+
const existingQuery = {
|
|
2734
|
+
_hash: docForInsert._hash,
|
|
2735
|
+
_model: modelName
|
|
2736
|
+
};
|
|
2737
|
+
if (user) existingQuery._user = username;
|
|
2738
|
+
|
|
2739
|
+
const existingDoc = await collection.findOne(existingQuery, {projection: {_id: 1}});
|
|
2740
|
+
if (existingDoc) {
|
|
2741
|
+
tempIdToNewIdMap[tempId] = existingDoc._id;
|
|
2742
|
+
summary.datas.skipped++;
|
|
2743
|
+
} else {
|
|
2744
|
+
docForInsert._temp_pack_id_for_mapping = tempId;
|
|
2745
|
+
docsToInsert.push(docForInsert);
|
|
2746
|
+
}
|
|
2747
|
+
}
|
|
2748
|
+
|
|
2749
|
+
if (docsToInsert.length > 0) {
|
|
2750
|
+
try {
|
|
2751
|
+
const finalDocsToInsert = docsToInsert.map(d => {
|
|
2752
|
+
const doc = {...d};
|
|
2753
|
+
delete doc._temp_pack_id_for_mapping;
|
|
2754
|
+
return doc;
|
|
2755
|
+
});
|
|
2756
|
+
|
|
2757
|
+
const result = await collection.insertMany(finalDocsToInsert, {ordered: false});
|
|
2758
|
+
summary.datas.inserted += result.insertedCount;
|
|
2759
|
+
|
|
2760
|
+
docsToInsert.forEach((doc, index) => {
|
|
2761
|
+
if (result.insertedIds[index]) {
|
|
2762
|
+
tempIdToNewIdMap[doc._temp_pack_id_for_mapping] = result.insertedIds[index];
|
|
2763
|
+
}
|
|
2764
|
+
});
|
|
2765
|
+
} catch (e) {
|
|
2766
|
+
summary.datas.failed += docsToInsert.length;
|
|
2767
|
+
errors.push(`Error inserting batch for ${modelName}: ${e.message}`);
|
|
2768
|
+
logger.error(`[Pack Install] Error on insertMany for model ${modelName}:`, e);
|
|
2769
|
+
}
|
|
2770
|
+
}
|
|
2771
|
+
}
|
|
2772
|
+
|
|
2773
|
+
// --- PASS 2: REFERENCE LINKING ---
|
|
2774
|
+
logger.info(`[Pack Install] Starting Pass 2: Linking ${linkQueue.length} references`);
|
|
2775
|
+
for (const linkOp of linkQueue) {
|
|
2776
|
+
const {sourceTempId, sourceModelName, fieldName, linkSelector} = linkOp;
|
|
2777
|
+
const sourceId = tempIdToNewIdMap[sourceTempId];
|
|
2778
|
+
|
|
2779
|
+
if (!sourceId) {
|
|
2780
|
+
logger.warn(`[LINK FAILED] Could not find newly inserted document for temp ID ${sourceTempId}. Skipping link.`);
|
|
2781
|
+
continue;
|
|
2782
|
+
}
|
|
2783
|
+
|
|
2784
|
+
try {
|
|
2785
|
+
const targetModelName = linkSelector._model;
|
|
2786
|
+
delete linkSelector._model;
|
|
2787
|
+
|
|
2788
|
+
const sourceModelDef = await getModel(sourceModelName, user);
|
|
2789
|
+
const fieldDef = sourceModelDef.fields.find(f => f.name === fieldName);
|
|
2790
|
+
|
|
2791
|
+
if (!fieldDef) {
|
|
2792
|
+
logger.warn(`[LINK FAILED] Field '${fieldName}' not found in source model '${sourceModelName}'`);
|
|
2793
|
+
errors.push(`[LINK FAILED] Field '${fieldName}' not found in source model '${sourceModelName}'`);
|
|
2794
|
+
summary.datas.failed++;
|
|
2795
|
+
continue;
|
|
2796
|
+
}
|
|
2797
|
+
|
|
2798
|
+
// Search for target documents
|
|
2799
|
+
const {data: targetDocs} = await searchData(
|
|
2800
|
+
{model: targetModelName, filter: linkSelector},
|
|
2801
|
+
user
|
|
2802
|
+
);
|
|
2803
|
+
|
|
2804
|
+
if (!targetDocs || targetDocs.length === 0) {
|
|
2805
|
+
const errorMsg = `[LINK FAILED] No target found for ${JSON.stringify(linkSelector)}`;
|
|
2806
|
+
logger.warn(errorMsg);
|
|
2807
|
+
errors.push(errorMsg);
|
|
2808
|
+
summary.datas.failed++;
|
|
2809
|
+
continue;
|
|
2810
|
+
}
|
|
2811
|
+
|
|
2812
|
+
// Update source document with reference
|
|
2813
|
+
const valueToSet = fieldDef.multiple
|
|
2814
|
+
? targetDocs.map(d => d._id.toString())
|
|
2815
|
+
: targetDocs[0]._id.toString();
|
|
2816
|
+
|
|
2817
|
+
await collection.updateOne(
|
|
2818
|
+
{_id: sourceId},
|
|
2819
|
+
{$set: {[fieldName]: valueToSet}}
|
|
2820
|
+
);
|
|
2821
|
+
summary.datas.updated++;
|
|
2822
|
+
|
|
2823
|
+
} catch (e) {
|
|
2824
|
+
const errorMsg = `[LINK CRITICAL] Error linking ${sourceModelName}.${fieldName}: ${e.message}`;
|
|
2825
|
+
logger.error(errorMsg, e.stack);
|
|
2826
|
+
errors.push(errorMsg);
|
|
2827
|
+
summary.datas.failed++;
|
|
2828
|
+
}
|
|
2829
|
+
}
|
|
2830
|
+
|
|
2831
|
+
if (options.installForUser && user?.username) {
|
|
2832
|
+
if (pack.name)
|
|
2833
|
+
await packsCollection.deleteOne({name: pack.name, _user: user.username});
|
|
2834
|
+
logger.info(`--- Creating pack '${pack.name}' for user... ---`);
|
|
2835
|
+
const packToCreate = {...pack, _id: undefined, private: true, _user: user.username};
|
|
2836
|
+
await packsCollection.insertOne(packToCreate);
|
|
2837
|
+
}
|
|
2838
|
+
|
|
2839
|
+
// Trigger event only if pack came from database (original behavior)
|
|
2840
|
+
if (typeof packIdentifier === 'string') {
|
|
2841
|
+
await Event.Trigger("OnPackInstalled", "event", "system", pack);
|
|
2842
|
+
}
|
|
2843
|
+
|
|
2844
|
+
const modifiedCount = summary.datas.inserted + summary.datas.updated;
|
|
2845
|
+
logger.info(`--- ${logPrefix} completed ---`);
|
|
2846
|
+
return {
|
|
2847
|
+
success: errors.length === 0,
|
|
2848
|
+
summary,
|
|
2849
|
+
errors,
|
|
2850
|
+
modifiedCount
|
|
2851
|
+
};
|
|
2852
|
+
}
|
|
2853
|
+
|
|
2854
|
+
export const installAllPacks = async () => {
|
|
2855
|
+
const packs = await getAllPacks();
|
|
2856
|
+
await packsCollection.deleteMany({_user: {$exists: false}});
|
|
2857
|
+
await packsCollection.insertMany(packs.map(p => ({...p, private: false})));
|
|
2858
|
+
}
|
|
2859
|
+
/**
|
|
2860
|
+
* Gère les traductions spécifiques à l'utilisateur et traite les données de manière récursive
|
|
2861
|
+
* pour l'anonymisation et la résolution des relations.
|
|
2862
|
+
*
|
|
2863
|
+
* Cette fonction est le point d'entrée principal. Elle charge temporairement les traductions
|
|
2864
|
+
* d'un utilisateur, traite les données, puis décharge les traductions pour garantir
|
|
2865
|
+
* qu'une requête n'affecte pas les autres.
|
|
2866
|
+
*
|
|
2867
|
+
* @param {object} model - La définition du modèle pour les données actuelles.
|
|
2868
|
+
* @param {object|Array<object>|null} data - Les données à traiter (un objet, un tableau ou null).
|
|
2869
|
+
* @param {object} user - L'objet utilisateur effectuant la requête.
|
|
2870
|
+
* @param {boolean} [isRecursiveCall=false] - Un drapeau interne pour éviter de recharger les traductions lors des appels récursifs.
|
|
2871
|
+
* @returns {Promise<object|Array<object>|null>} - Les données traitées, dans le même format que l'entrée.
|
|
2872
|
+
*/
|
|
2873
|
+
const handleFields = async (model, data, user, isRecursiveCall = false) => {
|
|
2874
|
+
// Détermine si l'entrée était un tableau pour retourner le même format.
|
|
2875
|
+
const wasArray = Array.isArray(data);
|
|
2876
|
+
// Normalise les données en tableau pour un traitement unifié. Gère le cas où data est null/undefined.
|
|
2877
|
+
const dataArray = wasArray ? data : (data ? [data] : []);
|
|
2878
|
+
|
|
2879
|
+
if (dataArray.length === 0) {
|
|
2880
|
+
return wasArray ? [] : null;
|
|
2881
|
+
}
|
|
2882
|
+
|
|
2883
|
+
// Fonction interne pour traiter les données. Appelée après la gestion des traductions.
|
|
2884
|
+
const _processItems = async (items) => {
|
|
2885
|
+
const canRead = !isLocalUser(user) || await hasPermission(["API_ADMIN", "API_DEANONYMIZED", "API_DEANONYMIZED_" + model.name], user);
|
|
2886
|
+
|
|
2887
|
+
for (const item of items) {
|
|
2888
|
+
if (!item) continue;
|
|
2889
|
+
|
|
2890
|
+
if (item['_id']) {
|
|
2891
|
+
item['_id'] = item['_id'].toString();
|
|
2892
|
+
}
|
|
2893
|
+
|
|
2894
|
+
for (const field of model.fields) {
|
|
2895
|
+
const fieldName = field.name;
|
|
2896
|
+
const fieldValue = item[fieldName];
|
|
2897
|
+
|
|
2898
|
+
if (fieldValue === undefined) continue;
|
|
2899
|
+
|
|
2900
|
+
// 1. Anonymisation des champs si nécessaire
|
|
2901
|
+
if (field.anonymized && !canRead && dataTypes[field.type]?.anonymize) {
|
|
2902
|
+
item[fieldName] = dataTypes[field.type].anonymize(fieldValue, field, getObjectHash({id: item._id}));
|
|
2903
|
+
}
|
|
2904
|
+
|
|
2905
|
+
if (field.type === 'string_t') {
|
|
2906
|
+
item[fieldName] = {key: fieldValue, value: i18n.t(fieldValue, fieldValue)};
|
|
2907
|
+
}
|
|
2908
|
+
|
|
2909
|
+
// 2. Résolution récursive des relations
|
|
2910
|
+
|
|
2911
|
+
if (field.type === 'relation' && fieldValue) {
|
|
2912
|
+
try {
|
|
2913
|
+
const relatedModel = await getModel(field.relation, user);
|
|
2914
|
+
// Appel récursif à la fonction principale, en signalant que ce n'est pas l'appel initial.
|
|
2915
|
+
item[fieldName] = await handleFields(relatedModel, fieldValue, user, true);
|
|
2916
|
+
if (!field.multiple && Array.isArray(item[fieldName]) && item[fieldName].length <= 1) {
|
|
2917
|
+
item[fieldName] = item[fieldName][0] || null;
|
|
2918
|
+
}
|
|
2919
|
+
} catch (e) {
|
|
2920
|
+
logger.warn(`Impossible de traiter la relation pour le champ '${fieldName}' du modèle '${model.name}'. Erreur: ${e.message}`);
|
|
2921
|
+
// En cas d'erreur (ex: modèle de relation introuvable), on conserve la valeur originale (probablement un ID).
|
|
2922
|
+
}
|
|
2923
|
+
}
|
|
2924
|
+
}
|
|
2925
|
+
}
|
|
2926
|
+
return items;
|
|
2927
|
+
};
|
|
2928
|
+
|
|
2929
|
+
// Si c'est l'appel initial (non récursif) et qu'un utilisateur est fourni, on gère les traductions.
|
|
2930
|
+
if (!isRecursiveCall && user?.username) {
|
|
2931
|
+
const lang = user.lang || 'en'; // Utilise la langue définie par le middleware, sinon 'en'.
|
|
2932
|
+
let originalTranslations = null;
|
|
2933
|
+
let userTranslationsLoaded = false;
|
|
2934
|
+
|
|
2935
|
+
|
|
2936
|
+
try {
|
|
2937
|
+
const coll = await getCollectionForUser(user);
|
|
2938
|
+
// 1. Récupérer l'ID du document de langue de l'utilisateur pour la langue actuelle.
|
|
2939
|
+
const userLangDoc = await coll.findOne({
|
|
2940
|
+
_model: 'lang',
|
|
2941
|
+
code: lang,
|
|
2942
|
+
_user: user.username
|
|
2943
|
+
});
|
|
2944
|
+
|
|
2945
|
+
if (userLangDoc) {
|
|
2946
|
+
// 2. Récupérer les traductions de l'utilisateur pour cette langue.
|
|
2947
|
+
const userTranslationsArray = await coll.find({
|
|
2948
|
+
_model: 'translation',
|
|
2949
|
+
_user: user.username,
|
|
2950
|
+
lang: userLangDoc._id.toString()
|
|
2951
|
+
}).toArray();
|
|
2952
|
+
|
|
2953
|
+
if (userTranslationsArray.length > 0) {
|
|
2954
|
+
// 3. Préparer le "bundle" de ressources pour i18n.
|
|
2955
|
+
const newResourceBundle = userTranslationsArray.reduce((acc, tr) => {
|
|
2956
|
+
if (tr.key && tr.value) {
|
|
2957
|
+
acc[tr.key] = tr.value;
|
|
2958
|
+
}
|
|
2959
|
+
return acc;
|
|
2960
|
+
}, {});
|
|
2961
|
+
|
|
2962
|
+
|
|
2963
|
+
// 4. Charger temporairement les traductions de l'utilisateur.
|
|
2964
|
+
if (Object.keys(newResourceBundle).length > 0) {
|
|
2965
|
+
// Sauvegarder les traductions originales si elles existent
|
|
2966
|
+
if (i18n.store.data[lang] && i18n.store.data[lang].translation) {
|
|
2967
|
+
originalTranslations = {...i18n.store.data[lang].translation};
|
|
2968
|
+
}
|
|
2969
|
+
// Ajoute/remplace les clés de traduction pour la langue et le namespace courants.
|
|
2970
|
+
i18n.addResourceBundle(lang, 'translation', newResourceBundle, true, true);
|
|
2971
|
+
userTranslationsLoaded = true;
|
|
2972
|
+
logger.debug(`Chargement de ${userTranslationsArray.length} traductions personnalisées pour l'utilisateur '${user.username}' en '${lang}'.`);
|
|
2973
|
+
}
|
|
2974
|
+
}
|
|
2975
|
+
}
|
|
2976
|
+
|
|
2977
|
+
// 5. Traiter les données avec les traductions (personnalisées ou par défaut).
|
|
2978
|
+
const processedData = await _processItems(dataArray);
|
|
2979
|
+
return wasArray ? processedData : processedData[0];
|
|
2980
|
+
} finally {
|
|
2981
|
+
// 6. Nettoyage : décharger les traductions de l'utilisateur et restaurer les originales.
|
|
2982
|
+
// Ce bloc s'exécute toujours, même en cas d'erreur, garantissant l'isolation des requêtes.
|
|
2983
|
+
if (userTranslationsLoaded) {
|
|
2984
|
+
// Supprime le namespace temporaire.
|
|
2985
|
+
i18n.removeResourceBundle(lang, 'translation');
|
|
2986
|
+
logger.debug(`Déchargement des traductions personnalisées pour l'utilisateur '${user.username}' en '${lang}'.`);
|
|
2987
|
+
|
|
2988
|
+
// Restaure les traductions originales si elles avaient été sauvegardées.
|
|
2989
|
+
if (originalTranslations) {
|
|
2990
|
+
i18n.addResourceBundle(lang, 'translation', originalTranslations, true, true);
|
|
2991
|
+
logger.debug(`Restauration des traductions originales pour la langue '${lang}'.`);
|
|
2992
|
+
}
|
|
2993
|
+
}
|
|
2994
|
+
}
|
|
2995
|
+
} else {
|
|
2996
|
+
// C'est un appel récursif ou il n'y a pas d'utilisateur, on traite donc directement les données.
|
|
2997
|
+
const processedData = await _processItems(dataArray);
|
|
2998
|
+
return wasArray ? processedData : processedData[0];
|
|
2999
|
+
}
|
|
3000
|
+
};
|