data-primals-engine 1.7.2 → 1.7.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/README.md +160 -160
  2. package/client/package-lock.json +8430 -8430
  3. package/client/src/APIInfo.jsx +1 -1
  4. package/client/src/App.jsx +2 -1
  5. package/client/src/App.scss +1635 -1626
  6. package/client/src/AssistantChat.jsx +1 -0
  7. package/client/src/CalendarView.jsx +1 -0
  8. package/client/src/ContentView.jsx +3 -3
  9. package/client/src/Dashboard.jsx +5 -2
  10. package/client/src/DashboardChart.jsx +1 -0
  11. package/client/src/DashboardFlexViewItem.jsx +1 -0
  12. package/client/src/DashboardHtmlViewItem.jsx +1 -0
  13. package/client/src/DashboardView.jsx +2 -0
  14. package/client/src/DataEditor.jsx +0 -1
  15. package/client/src/DataImporter.jsx +489 -468
  16. package/client/src/DataLayout.jsx +6 -3
  17. package/client/src/DataTable.jsx +4 -3
  18. package/client/src/Dialog.jsx +92 -90
  19. package/client/src/Dialog.scss +122 -116
  20. package/client/src/DisplayFlexNodeRenderer.jsx +1 -0
  21. package/client/src/FlexBuilderControls.jsx +1 -0
  22. package/client/src/FlexBuilderPreview.jsx +1 -1
  23. package/client/src/FlexNode.jsx +1 -1
  24. package/client/src/HistoryDialog.jsx +3 -2
  25. package/client/src/KPIWidget.jsx +1 -1
  26. package/client/src/KanbanView.jsx +1 -0
  27. package/client/src/ModelCreator.jsx +4 -0
  28. package/client/src/ModelList.jsx +1 -0
  29. package/client/src/PackGallery.jsx +5 -4
  30. package/client/src/RTETrans.jsx +1 -1
  31. package/client/src/RelationField.jsx +2 -0
  32. package/client/src/RelationSelectorWidget.jsx +2 -0
  33. package/client/src/RelationValue.jsx +1 -0
  34. package/client/src/RestoreDialog.jsx +1 -0
  35. package/client/src/WorkflowEditor.jsx +3 -0
  36. package/client/src/contexts/CommandContext.jsx +2 -1
  37. package/client/src/contexts/ModelContext.jsx +3 -3
  38. package/client/src/hooks/data.js +1 -0
  39. package/client/src/hooks/useValidation.js +1 -0
  40. package/client/src/translations.js +24 -24
  41. package/doc/AI-assistance.md +87 -63
  42. package/doc/Concepts.md +122 -0
  43. package/doc/Custom-Endpoints.md +31 -0
  44. package/doc/Event-system.md +13 -14
  45. package/doc/Home.md +33 -0
  46. package/doc/Modules.md +83 -0
  47. package/doc/Packs-gallery.md +8 -23
  48. package/doc/Workflows.md +32 -0
  49. package/doc/automation-workflows.md +141 -102
  50. package/doc/dashboards-kpis-charts.md +47 -49
  51. package/doc/data-management.md +126 -120
  52. package/doc/data-models.md +68 -75
  53. package/doc/roles-permissions.md +144 -43
  54. package/doc/sharding-replication.md +158 -0
  55. package/doc/users.md +54 -30
  56. package/package.json +1 -1
  57. package/server.js +37 -37
  58. package/src/constants.js +7 -8
  59. package/src/data.js +521 -520
  60. package/src/email.js +157 -154
  61. package/src/engine.js +117 -7
  62. package/src/gameObject.js +6 -0
  63. package/src/i18n.js +0 -1
  64. package/src/modules/auth-google/index.js +53 -50
  65. package/src/modules/bucket.js +346 -339
  66. package/src/modules/data/data.backup.js +400 -376
  67. package/src/modules/data/data.cluster.js +410 -42
  68. package/src/modules/data/data.operations.js +3666 -3635
  69. package/src/modules/data/data.replication.js +106 -64
  70. package/src/modules/data/data.routes.js +87 -64
  71. package/src/modules/data/data.scheduling.js +2 -1
  72. package/src/modules/file.js +248 -247
  73. package/src/modules/user.js +11 -1
  74. package/src/sso.js +91 -25
  75. package/test/cluster.test.js +221 -0
  76. package/test/core.test.js +0 -2
  77. package/test/replication.test.js +163 -0
  78. package/doc/core-concepts.md +0 -33
package/src/data.js CHANGED
@@ -1,521 +1,522 @@
1
- import {getObjectHash} from "./core.js";
2
- import process from "node:process";
3
- import crypto from "node:crypto";
4
- import {mainFieldsTypes} from "./constants.js";
5
-
6
- const IV_LENGTH = 16;
7
- export function encryptValue(text) {
8
- if (!process.env.ENCRYPTION_KEY) throw new Error("ENCRYPTION_KEY is not set");
9
- let iv = crypto.randomBytes(IV_LENGTH);
10
- let cipher = crypto.createCipheriv('aes-256-cbc', Buffer.from(process.env.ENCRYPTION_KEY), iv);
11
- let encrypted = cipher.update(text);
12
- encrypted = Buffer.concat([encrypted, cipher.final()]);
13
- return iv.toString('hex') + ':' + encrypted.toString('hex');
14
- }
15
-
16
- export function decryptValue(text) {
17
- if (!process.env.ENCRYPTION_KEY) throw new Error("ENCRYPTION_KEY is not set");
18
- if (!text || typeof text !== 'string' || !text.includes(':')) return text; // ou throw error
19
- let textParts = text.split(':');
20
- let iv = Buffer.from(textParts.shift(), 'hex');
21
- let encryptedText = Buffer.from(textParts.join(':'), 'hex');
22
- let decipher = crypto.createDecipheriv('aes-256-cbc', Buffer.from(process.env.ENCRYPTION_KEY), iv);
23
- let decrypted = decipher.update(encryptedText);
24
- decrypted = Buffer.concat([decrypted, decipher.final()]);
25
- return decrypted.toString();
26
- }
27
-
28
- export const isLocalUser = (user) => {
29
- return user && user._model === 'user' && typeof(user._user) === 'string' && user._user.trim() !== '';
30
- };
31
-
32
- export const isDemoUser = (user) => {
33
- return user?.temporary;
34
- }
35
-
36
- export function getUserHash(user) {
37
- if( isDemoUser(user) ){
38
- return user.username;
39
- }
40
- if( user.hash )
41
- return user.hash;
42
- return user ? (
43
- isLocalUser(user) ? getObjectHash({id: 'LOCAL_USER'+user._user+user.username}) : getObjectHash({id: user.hash})
44
- ) : 0;
45
- }
46
- export function getUserId(user) {
47
- return user ? (
48
- isLocalUser(user) ? ('LOCAL_USER'+user.username)?.hashCode() : user.username
49
- ) : 0;
50
- }
51
-
52
- export const getUserName = (user) => {
53
- return isLocalUser(user) ? user._id + '_'+user._user : user.username;
54
- }
55
-
56
- /**
57
- * Crée une fonction de génération de nombres pseudo-aléatoires basée sur une graine (seed).
58
- * Utilise un simple générateur congruentiel linéaire (LCG).
59
- * @param {number} seed - La graine initiale.
60
- * @returns {function(): number} Une fonction qui retourne un nombre entre 0 et 1.
61
- */
62
- function createSeededRandom(seed) {
63
- let state = seed;
64
- return function() {
65
- // Algorithme LCG simple. Pas pour la cryptographie, mais suffisant pour du "hasard" déterministe.
66
- state = (state * 9301 + 49297) % 233280;
67
- return state / 233280.0;
68
- };
69
- }
70
-
71
- /**
72
- * Anonymise une chaîne de caractères en remplaçant chaque caractère
73
- * par un caractère aléatoire d'un jeu défini.
74
- * Cette méthode est déterministe si une graine (seed) est fournie.
75
- *
76
- * @param {string} text Le texte à anonymiser.
77
- * @param {boolean} [preserveSpaces=false] Si true, les espaces ne seront pas remplacés.
78
- * @param {string|number|null} [seed=null] Une graine pour rendre l'anonymisation déterministe. Peut être le _hash de la donnée.
79
- * @returns {string} Le texte anonymisé, ou une chaîne vide si l'entrée est invalide.
80
- */
81
- export function anonymizeText(text, preserveSpaces = false, seed = null) {
82
- if (typeof text !== 'string' || text.length === 0) {
83
- return "";
84
- }
85
-
86
- text = encryptValue(text);
87
-
88
- // Jeu de caractères pour le remplacement (vous pouvez l'adapter)
89
- const charSet = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789*#@!?";
90
- let anonymizedResult = "";
91
-
92
- let random;
93
- if (seed !== null) {
94
- // Convertir la graine (qui peut être une chaîne comme _hash) en un nombre simple.
95
- let numericSeed = 0;
96
- if (typeof seed === 'string') {
97
- for (let i = 0; i < seed.length; i++) {
98
- // Simple hash to number. Using a bitwise operation to keep it a 32-bit integer.
99
- numericSeed = (((numericSeed << 5) - numericSeed) + seed.charCodeAt(i)) | 0;
100
- }
101
- } else if (typeof seed === 'number') {
102
- numericSeed = seed;
103
- } else {
104
- // Fallback pour d'autres types, bien que non attendu
105
- numericSeed = new Date().getTime();
106
- }
107
- random = createSeededRandom(numericSeed);
108
- } else {
109
- // Si aucune graine n'est fournie, utiliser le générateur aléatoire standard.
110
- random = Math.random;
111
- }
112
-
113
- for (let i = 0; i < text.length; i++) {
114
- const originalChar = text[i];
115
-
116
- // Conserver les espaces si demandé
117
- if (preserveSpaces && (originalChar === ' ' || originalChar === '\t' || originalChar === '\n' || originalChar === '\r')) {
118
- anonymizedResult += originalChar;
119
- } else {
120
- // Remplacer par un caractère aléatoire du jeu en utilisant le générateur choisi
121
- const randomIndex = Math.floor(random() * charSet.length);
122
- anonymizedResult += charSet[randomIndex];
123
- }
124
- }
125
-
126
- return anonymizedResult;
127
- }
128
-
129
-
130
- /**
131
- * Get the value from an object based on a dot-separated path.
132
- * @param {object} obj - The object to traverse.
133
- * @param {string} path - The dot-separated path (e.g., "user.profile.name").
134
- * @returns {*} The value at the given path, or undefined if not found.
135
- */
136
- export const getFieldPathValue = (obj, path) => {
137
- if (!obj || !path) return undefined;
138
- const properties = path.split('.');
139
- return properties.reduce((prev, curr) => (prev && prev[curr] !== undefined) ? prev[curr] : undefined, obj);
140
- };
141
-
142
- /**
143
- * Apply a filter condition to an array of data objects.
144
- * Filter format is a simple object mapping field paths to values or filter operators.
145
- * Supported operators: $eq, $ne, $in (array), $exists (boolean).
146
- * @param {array} data - The array of data objects.
147
- * @param {object|null} filter - The filter object.
148
- * @returns {array} The filtered array.
149
- */
150
- export const applyFilter = (data, filter) => {
151
- if (!filter || Object.keys(filter).length === 0 || !Array.isArray(data)) return data;
152
- return data.filter(item => {
153
- for (const key in filter) {
154
- // Ensure the key exists in the filter and is not from prototype chain
155
- if (Object.prototype.hasOwnProperty.call(filter, key)) {
156
- const filterValue = filter[key];
157
- const itemValue = getFieldPathValue(item, key);
158
-
159
- if (typeof filterValue === 'object' && filterValue !== null) {
160
- if (filterValue.$in && Array.isArray(filterValue.$in)) {
161
- if (!filterValue.$in.includes(itemValue)) return false;
162
- } else if (filterValue.$eq !== undefined) {
163
- if (itemValue !== filterValue.$eq) return false;
164
- } else if (filterValue.$ne !== undefined) {
165
- if (itemValue === filterValue.$ne) return false;
166
- } else if (filterValue.$exists !== undefined) {
167
- const hasValue = itemValue !== undefined && itemValue !== null;
168
- if (filterValue.$exists && !hasValue) return false;
169
- if (!filterValue.$exists && hasValue) return false;
170
- }
171
- // Add support for other operators if needed
172
- // else if (filterValue.$gt !== undefined) { ... }
173
- } else {
174
- // Default equality check for non-object filter values
175
- if (itemValue !== filterValue) return false;
176
- }
177
- }
178
- }
179
- return true;
180
- });
181
- };
182
-
183
- export function getDefaultForType(field) {
184
- if(field.default)
185
- return typeof(field.default) === 'function' ? field.default() : field.default;
186
- switch (field.type) {
187
- case 'string':
188
- case 'string_t':
189
- case 'richtext':
190
- case 'password':
191
- case 'email':
192
- case 'phone':
193
- case 'url':
194
- return '';
195
- case 'model':
196
- return '';
197
- case 'modelField':
198
- return { model: '', field: '' };
199
- case 'color':
200
- return null;
201
- case 'number':
202
- return 0;
203
- case 'date':
204
- case 'datetime':
205
- return null;
206
- case 'boolean':
207
- return false;
208
- case 'file':
209
- return null;
210
- case 'array':
211
- return [];
212
- case 'enum':
213
- return field.required && field.items && field.items.length > 0 ? field.items[0] : null;
214
- case 'object':
215
- return {};
216
- case 'relation':
217
- return field.multiple ? [] : null;
218
- default:
219
-
220
- return undefined;
221
- }
222
- }
223
-
224
-
225
- /**
226
- * Maps ConditionBuilder operators to MongoDB aggregation expression operators
227
- * or indicates special handling.
228
- */
229
- const operatorToExprOperatorMap = {
230
- '$eq': '$eq',
231
- '$ne': '$ne',
232
- '$gt': '$gt',
233
- '$gte': '$gte',
234
- '$lt': '$lt',
235
- '$lte': '$lte',
236
- '$in': '$in',
237
- '$nin': '$nin', // Special handling with $not/$in
238
- '$regex': '$regexMatch', // Use aggregation regex operator
239
- '$exists': '$exists' // Special handling with $type or similar
240
- };
241
-
242
- // Helper to build the nested $find structure
243
- function buildNestedFindStructure(pathSegments, finalPayload) {
244
- // Base case: If no segments left, return the final condition payload
245
- if (!pathSegments || pathSegments.length === 0) {
246
- return finalPayload;
247
- }
248
-
249
- const currentSegment = pathSegments[0];
250
- const remainingSegments = pathSegments.slice(1);
251
-
252
- // Recursively build the structure
253
- return {
254
- [currentSegment]: {
255
- '$find': buildNestedFindStructure(remainingSegments, finalPayload)
256
- }
257
- };
258
- }
259
- // ... (imports and other functions) ...
260
-
261
- /**
262
- * Builds the core condition payload (operators and values, or logical groups)
263
- * from a ConditionBuilder condition object or sub-object.
264
- * It handles the transformation of simple conditions and logical groups into
265
- * the format expected by the API filter, using $expr for comparisons and $find for nesting.
266
- *
267
- * @param {object} conditionNode - A node from the ConditionBuilder structure.
268
- * @returns {object | null} The condition payload object or null if invalid.
269
- */
270
- function buildApiConditionPayloadRecursive(conditionNode) {
271
- if (!conditionNode || typeof conditionNode !== 'object') {
272
- console.warn("Invalid node in buildApiConditionPayloadRecursive:", conditionNode);
273
- return null;
274
- }
275
-
276
- // --- Handle Logical Operators ($and, $or) ---
277
- if (conditionNode.$and && Array.isArray(conditionNode.$and)) {
278
- const subPayloads = conditionNode.$and.map(buildApiConditionPayloadRecursive).filter(p => p !== null);
279
- if (subPayloads.length === 0) return null;
280
- if (subPayloads.length === 1) return subPayloads[0];
281
- return { '$and': subPayloads };
282
- }
283
- if (conditionNode.$or && Array.isArray(conditionNode.$or)) {
284
- const subPayloads = conditionNode.$or.map(buildApiConditionPayloadRecursive).filter(p => p !== null);
285
- if (subPayloads.length === 0) return null;
286
- if (subPayloads.length === 1) return subPayloads[0];
287
- return { '$or': subPayloads };
288
- }
289
-
290
- // --- Handle Simple Condition { model?, path, op, value } ---
291
- if (conditionNode.path && Array.isArray(conditionNode.path) && conditionNode.op) {
292
- const path = conditionNode.path;
293
-
294
- if (path.length === 0) {
295
- console.warn("Simple condition node has empty path:", conditionNode);
296
- return null;
297
- }
298
-
299
- // --- Build the core $expr payload ---
300
- const finalFieldName = path[path.length - 1];
301
- const segmentsForNesting = path.slice(0, -1); // Path segments before the last one
302
-
303
- // ***** CORRECTED LOGIC *****
304
- // Determine the field path for the $expr based on nesting level
305
- const fieldPathForExpr = segmentsForNesting.length === 0
306
- ? `$${finalFieldName}` // Top-level field: $fieldName
307
- : `$$this.${finalFieldName}`; // Nested field (inside $find): $$this.fieldName
308
- // ***** END CORRECTION *****
309
-
310
- const operator = conditionNode.op;
311
- const value = conditionNode.value;
312
- const exprOperator = operatorToExprOperatorMap[operator];
313
-
314
- let exprPayload; // This will hold the { $operator: [ field, value ] } part
315
-
316
- if (!exprOperator) {
317
- console.warn(`Unsupported operator for $expr conversion: ${operator}`);
318
- return null;
319
- }
320
-
321
- // --- Build the specific $expr based on the operator ---
322
- switch (exprOperator) {
323
- case '$nin':
324
- const ninValueArray = Array.isArray(value) ? value : String(value).split(',').map(s => s.trim()).filter(Boolean);
325
- // Note: $nin at the top level is often better handled *outside* $expr
326
- // but for consistency with $find, we keep it inside $expr here.
327
- // If segmentsForNesting.length === 0, a standard query { field: { $nin: [...] } } might be more efficient.
328
- exprPayload = { '$not': { '$in': [fieldPathForExpr, ninValueArray] } };
329
- break;
330
- case '$in':
331
- const inValueArray = Array.isArray(value) ? value : String(value).split(',').map(s => s.trim()).filter(Boolean);
332
- // Similar note as $nin regarding top-level efficiency.
333
- exprPayload = { '$in': [fieldPathForExpr, inValueArray] };
334
- break;
335
- case '$regexMatch':
336
- exprPayload = {
337
- '$regexMatch': {
338
- input: fieldPathForExpr,
339
- regex: String(value),
340
- options: 'i'
341
- }
342
- };
343
- break;
344
- case '$exists':
345
- const shouldExist = typeof value === 'boolean' ? value : String(value).toLowerCase() === 'true';
346
- // Using $ne/$eq with $type is a common way to check existence in $expr
347
- exprPayload = {
348
- [shouldExist ? '$ne' : '$eq']: [ { $type: fieldPathForExpr }, "undefined" ]
349
- };
350
- // Note: For top-level, { field: { $exists: true/false } } is standard.
351
- break;
352
- default:
353
- // Standard binary operators ($eq, $ne, $gt, $gte, $lt, $lte)
354
- let processedValue = value;
355
- // Basic type guessing for common string values
356
- if (operator === '$eq' || operator === '$ne') {
357
- if (value === 'true') processedValue = true;
358
- else if (value === 'false') processedValue = false;
359
- else if (value === 'null') processedValue = null;
360
- // Avoid automatic number conversion for eq/ne unless explicitly needed
361
- } else if (['$gt', '$gte', '$lt', '$lte'].includes(operator)) {
362
- // Attempt number conversion for comparison operators
363
- const num = Number(value);
364
- if (!isNaN(num) && String(value).trim() !== '') {
365
- processedValue = num;
366
- }
367
- // TODO: Consider adding date conversion if field type is known
368
- }
369
- exprPayload = { [exprOperator]: [fieldPathForExpr, processedValue] };
370
- break;
371
- }
372
-
373
- // The final payload for the innermost level is the $expr object
374
- const corePayload = exprPayload;
375
-
376
- // --- Structure the filter using nested $find based on the path segments before the last one ---
377
- return buildNestedFindStructure(segmentsForNesting, corePayload);
378
- }
379
-
380
- // Log warning for unknown structures
381
- console.warn("Unknown structure encountered in buildApiConditionPayloadRecursive:", conditionNode);
382
- return null;
383
- }
384
-
385
-
386
- /**
387
- * Transforms a ConditionBuilder condition object into the specific filter format
388
- * for the /api/data/search endpoint.
389
- * The final format is { filter: { ... } }.
390
- *
391
- * @param {object | null | undefined} condition - The condition object from ConditionBuilder.
392
- * @returns {object} The filter object in the format { filter: { ... } }.
393
- * Returns { filter: {} } if the condition is invalid/empty.
394
- */
395
- export function conditionToApiSearchFilter(condition) {
396
- // Handle null or invalid input condition
397
- if (!condition || typeof condition !== 'object') {
398
- return { filter: {} }; // Return empty filter for invalid input
399
- }
400
-
401
- // Build the main filter content using the recursive payload builder
402
- const filterContent = buildApiConditionPayloadRecursive(condition);
403
-
404
- // Return the final structure, ensuring filterContent is not null
405
- return { filter: filterContent || {} };
406
- }
407
-
408
-
409
- export const getFieldValueHash = (model, data) => {
410
- // Prioritize composite unique constraints if they exist
411
- const uniqueConstraints = model.constraints?.filter(c => c.type === 'unique') || [];
412
-
413
- if (uniqueConstraints.length > 0) {
414
- // Combine keys from all unique constraints to create a truly unique hash.
415
- const constraintKeys = [...new Set(uniqueConstraints.flatMap(c => c.keys))];
416
- return getObjectHash(data, constraintKeys, 'constraint');
417
- }
418
- const uniqueFields = model.fields.filter(f => f.unique).map(m => m.name);
419
- const fs = model.fields.map(m => m.name);
420
- const fields = ['_model', '_user', ...(uniqueFields.length ? uniqueFields : fs)];
421
- return getObjectHash(data, fields);
422
- }
423
-
424
-
425
- /**
426
- * Obtient une représentation textuelle lisible d'un enregistrement de données.
427
- * Gère les relations de manière récursive pour afficher des informations pertinentes.
428
- *
429
- * @param {object} model - La définition du modèle pour la `data` actuelle.
430
- * @param {object} data - L'enregistrement de données à convertir en chaîne de caractères.
431
- * @param {function} t - La fonction de traduction i18next.
432
- * @param {array} allModels - Un tableau contenant les définitions de TOUS les modèles du système.
433
- * @returns {string} - La chaîne de caractères représentant la donnée.
434
- */
435
- export const getDataAsString = (model, data, tr, allModels, extended=false) => {
436
- const { t, i18n} = tr;
437
- const lang = (i18n.resolvedLanguage || i18n.language).split(/[-_]/)?.[0];
438
- // Cas de base : si le modèle ou les données sont manquants, on ne peut rien faire.
439
- if (!model || !data) {
440
- return '';
441
- }
442
-
443
- // 1. Déterminer les champs à afficher
444
- // On privilégie les champs marqués comme "asMain"
445
- let parts;
446
- if( extended )
447
- parts = model.fields.map(m => m.name);
448
- else
449
- parts = model.fields.filter(f => f.asMain).map(m => m.name);
450
-
451
- // 2. Si aucun champ "asMain", on cherche des champs standards (nom, titre, etc.)
452
- if (!parts.length) {
453
- for (const main of mainFieldsTypes) {
454
- const f = model.fields?.find((f) => f.type === main)?.name;
455
- if (f) {
456
- parts.push(f);
457
- break; // On ne prend que le premier trouvé
458
- }
459
- }
460
- }
461
-
462
- // 3. Si toujours rien, on se rabat sur l'_id
463
- if (!parts.length) {
464
- parts.push('_id');
465
- }
466
-
467
- // 4. Construire la chaîne de caractères finale
468
- const resultString = parts.map(fieldName => {
469
- const fieldDef = model.fields.find(f => f.name === fieldName);
470
- const value = data[fieldName];
471
-
472
- if (!fieldDef || value === null || value === undefined) {
473
- return null; // Ignore les champs ou valeurs non définis
474
- }
475
-
476
- // --- NOUVELLE LOGIQUE POUR LES RELATIONS ---
477
- if (fieldDef.type === 'relation' && value) {
478
- const relatedModel = allModels?.find(m => m.name === fieldDef.relation);
479
- if (!relatedModel) return `[${fieldDef.relation}]`; // Modnon trouvé
480
-
481
- // Si la relation est multiple (un tableau d'objets)
482
- if (Array.isArray(value)) {
483
- return value
484
- .map(item => getDataAsString(relatedModel, item, t, allModels)) // Appel récursif pour chaque item
485
- .filter(Boolean)
486
- .join(', ');
487
- }
488
- // Si la relation est simple (un seul objet)
489
- else if (typeof value === 'object') {
490
- return getDataAsString(relatedModel, value, t, allModels); // Appel récursif
491
- }
492
- }
493
-
494
- if(fieldDef.type === 'datetime'){
495
- return new Date(value).toLocaleDateString(lang, {year: 'numeric', month: 'numeric', day: 'numeric', hour: 'numeric', minute: 'numeric'});
496
- }
497
- if(fieldDef.type === 'date'){
498
- return new Date(value).toLocaleDateString(lang, {year: 'numeric', month: 'numeric', day: 'numeric'});
499
- }
500
- // --- FIN DE LA NOUVELLE LOGIQUE ---
501
-
502
- // Logique existante pour les autres types de champs
503
- if (value.value !== undefined) {
504
- return value.value; // Champs traduits
505
- }
506
-
507
- if( typeof(value) === 'string' ) {
508
- const translatedValue = t(value, {defaultValue: value});
509
- return translatedValue || value.toString();
510
- }
511
- if( typeof(value) === 'object' ) {
512
- return JSON.stringify(value, null, 2);
513
- }
514
- return value;
515
-
516
- }).filter(Boolean).join(', ');
517
-
518
- // Si après tout ça la chaîne est vide (ex: l'ID est le seul champ mais on ne veut pas l'afficher seul)
519
- // On retourne l'ID pour avoir au moins un identifiant.
520
- return resultString || data._id;
1
+ import {getObjectHash} from "./core.js";
2
+ import process from "node:process";
3
+ import crypto from "node:crypto";
4
+ import {mainFieldsTypes} from "./constants.js";
5
+
6
+ const IV_LENGTH = 16;
7
+ export function encryptValue(text) {
8
+ if (!process.env.ENCRYPTION_KEY) throw new Error("ENCRYPTION_KEY is not set");
9
+ let iv = crypto.randomBytes(IV_LENGTH);
10
+ let cipher = crypto.createCipheriv('aes-256-cbc', Buffer.from(process.env.ENCRYPTION_KEY), iv);
11
+ let encrypted = cipher.update(text);
12
+ encrypted = Buffer.concat([encrypted, cipher.final()]);
13
+ return iv.toString('hex') + ':' + encrypted.toString('hex');
14
+ }
15
+
16
+ export function decryptValue(text) {
17
+ if (!process.env.ENCRYPTION_KEY) throw new Error("ENCRYPTION_KEY is not set");
18
+ if (!text || typeof text !== 'string' || !text.includes(':')) return text; // ou throw error
19
+ let textParts = text.split(':');
20
+ let iv = Buffer.from(textParts.shift(), 'hex');
21
+ let encryptedText = Buffer.from(textParts.join(':'), 'hex');
22
+ let decipher = crypto.createDecipheriv('aes-256-cbc', Buffer.from(process.env.ENCRYPTION_KEY), iv);
23
+ let decrypted = decipher.update(encryptedText);
24
+ decrypted = Buffer.concat([decrypted, decipher.final()]);
25
+ return decrypted.toString();
26
+ }
27
+
28
+ export const isLocalUser = (user) => {
29
+ return user && user._model === 'user' && typeof(user._user) === 'string' && user._user.trim() !== '';
30
+ };
31
+
32
+ export const isDemoUser = (user) => {
33
+ return user?.temporary;
34
+ }
35
+
36
+ export function getUserHash(user) {
37
+ if( isDemoUser(user) ){
38
+ return user.username;
39
+ }
40
+ if( user.hash )
41
+ return user.hash;
42
+ return user ? (
43
+ isLocalUser(user) ? getObjectHash({id: 'LOCAL_USER'+user._user+user.username}) : getObjectHash({id: user.hash})
44
+ ) : 0;
45
+ }
46
+ export function getUserId(user) {
47
+ return user ? (
48
+ isLocalUser(user) ? ('LOCAL_USER'+user.username)?.hashCode() : user.username
49
+ ) : 0;
50
+ }
51
+
52
+ export const getUserName = (user) => {
53
+ return isLocalUser(user) ? user._id + '_'+user._user : user.username;
54
+ }
55
+
56
+ /**
57
+ * Crée une fonction de génération de nombres pseudo-aléatoires basée sur une graine (seed).
58
+ * Utilise un simple générateur congruentiel linéaire (LCG).
59
+ * @param {number} seed - La graine initiale.
60
+ * @returns {function(): number} Une fonction qui retourne un nombre entre 0 et 1.
61
+ */
62
+ function createSeededRandom(seed) {
63
+ let state = seed;
64
+ return function() {
65
+ // Algorithme LCG simple. Pas pour la cryptographie, mais suffisant pour du "hasard" déterministe.
66
+ state = (state * 9301 + 49297) % 233280;
67
+ return state / 233280.0;
68
+ };
69
+ }
70
+
71
+ /**
72
+ * Anonymise une chaîne de caractères en remplaçant chaque caractère
73
+ * par un caractère aléatoire d'un jeu défini.
74
+ * Cette méthode est déterministe si une graine (seed) est fournie.
75
+ *
76
+ * @param {string} text Le texte à anonymiser.
77
+ * @param {boolean} [preserveSpaces=false] Si true, les espaces ne seront pas remplacés.
78
+ * @param {string|number|null} [seed=null] Une graine pour rendre l'anonymisation déterministe. Peut être le _hash de la donnée.
79
+ * @returns {string} Le texte anonymisé, ou une chaîne vide si l'entrée est invalide.
80
+ */
81
+ export function anonymizeText(text, preserveSpaces = false, seed = null) {
82
+ if (typeof text !== 'string' || text.length === 0) {
83
+ return "";
84
+ }
85
+
86
+ text = encryptValue(text);
87
+
88
+ // Jeu de caractères pour le remplacement (vous pouvez l'adapter)
89
+ const charSet = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789*#@!?";
90
+ let anonymizedResult = "";
91
+
92
+ let random;
93
+ if (seed !== null) {
94
+ // Convertir la graine (qui peut être une chaîne comme _hash) en un nombre simple.
95
+ let numericSeed = 0;
96
+ if (typeof seed === 'string') {
97
+ for (let i = 0; i < seed.length; i++) {
98
+ // Simple hash to number. Using a bitwise operation to keep it a 32-bit integer.
99
+ numericSeed = (((numericSeed << 5) - numericSeed) + seed.charCodeAt(i)) | 0;
100
+ }
101
+ } else if (typeof seed === 'number') {
102
+ numericSeed = seed;
103
+ } else {
104
+ // Fallback pour d'autres types, bien que non attendu
105
+ numericSeed = new Date().getTime();
106
+ }
107
+ random = createSeededRandom(numericSeed);
108
+ } else {
109
+ // Si aucune graine n'est fournie, utiliser le générateur aléatoire standard.
110
+ random = Math.random;
111
+ }
112
+
113
+ for (let i = 0; i < text.length; i++) {
114
+ const originalChar = text[i];
115
+
116
+ // Conserver les espaces si demandé
117
+ if (preserveSpaces && (originalChar === ' ' || originalChar === '\t' || originalChar === '\n' || originalChar === '\r')) {
118
+ anonymizedResult += originalChar;
119
+ } else {
120
+ // Remplacer par un caractère aléatoire du jeu en utilisant le générateur choisi
121
+ const randomIndex = Math.floor(random() * charSet.length);
122
+ anonymizedResult += charSet[randomIndex];
123
+ }
124
+ }
125
+
126
+ return anonymizedResult;
127
+ }
128
+
129
+
130
+ /**
131
+ * Get the value from an object based on a dot-separated path.
132
+ * @param {object} obj - The object to traverse.
133
+ * @param {string} path - The dot-separated path (e.g., "user.profile.name").
134
+ * @returns {*} The value at the given path, or undefined if not found.
135
+ */
136
+ export const getFieldPathValue = (obj, path) => {
137
+ if (!obj || !path) return undefined;
138
+ const properties = path.split('.');
139
+ return properties.reduce((prev, curr) => (prev && prev[curr] !== undefined) ? prev[curr] : undefined, obj);
140
+ };
141
+
142
+ /**
143
+ * Apply a filter condition to an array of data objects.
144
+ * Filter format is a simple object mapping field paths to values or filter operators.
145
+ * Supported operators: $eq, $ne, $in (array), $exists (boolean).
146
+ * @param {array} data - The array of data objects.
147
+ * @param {object|null} filter - The filter object.
148
+ * @returns {array} The filtered array.
149
+ */
150
+ export const applyFilter = (data, filter) => {
151
+ if (!filter || Object.keys(filter).length === 0 || !Array.isArray(data)) return data;
152
+ return data.filter(item => {
153
+ for (const key in filter) {
154
+ // Ensure the key exists in the filter and is not from prototype chain
155
+ if (Object.prototype.hasOwnProperty.call(filter, key)) {
156
+ const filterValue = filter[key];
157
+ const itemValue = getFieldPathValue(item, key);
158
+
159
+ if (typeof filterValue === 'object' && filterValue !== null) {
160
+ if (filterValue.$in && Array.isArray(filterValue.$in)) {
161
+ if (!filterValue.$in.includes(itemValue)) return false;
162
+ } else if (filterValue.$eq !== undefined) {
163
+ if (itemValue !== filterValue.$eq) return false;
164
+ } else if (filterValue.$ne !== undefined) {
165
+ if (itemValue === filterValue.$ne) return false;
166
+ } else if (filterValue.$exists !== undefined) {
167
+ const hasValue = itemValue !== undefined && itemValue !== null;
168
+ if (filterValue.$exists && !hasValue) return false;
169
+ if (!filterValue.$exists && hasValue) return false;
170
+ }
171
+ // Add support for other operators if needed
172
+ // else if (filterValue.$gt !== undefined) { ... }
173
+ } else {
174
+ // Default equality check for non-object filter values
175
+ if (itemValue !== filterValue) return false;
176
+ }
177
+ }
178
+ }
179
+ return true;
180
+ });
181
+ };
182
+
183
+ export function getDefaultForType(field) {
184
+ if(field.default)
185
+ return typeof(field.default) === 'function' ? field.default() : field.default;
186
+ switch (field.type) {
187
+ case 'string':
188
+ case 'string_t':
189
+ case 'richtext':
190
+ case 'password':
191
+ case 'email':
192
+ case 'phone':
193
+ case 'url':
194
+ return '';
195
+ case 'model':
196
+ return '';
197
+ case 'modelField':
198
+ return { model: '', field: '' };
199
+ case 'color':
200
+ return null;
201
+ case 'number':
202
+ return 0;
203
+ case 'date':
204
+ case 'datetime':
205
+ return null;
206
+ case 'boolean':
207
+ return false;
208
+ case 'file':
209
+ return null;
210
+ case 'array':
211
+ return [];
212
+ case 'enum':
213
+ return field.required && field.items && field.items.length > 0 ? field.items[0] : null;
214
+ case 'object':
215
+ return {};
216
+ case 'relation':
217
+ return field.multiple ? [] : null;
218
+ default:
219
+
220
+ return undefined;
221
+ }
222
+ }
223
+
224
+
225
+ /**
226
+ * Maps ConditionBuilder operators to MongoDB aggregation expression operators
227
+ * or indicates special handling.
228
+ */
229
+ const operatorToExprOperatorMap = {
230
+ '$eq': '$eq',
231
+ '$ne': '$ne',
232
+ '$gt': '$gt',
233
+ '$gte': '$gte',
234
+ '$lt': '$lt',
235
+ '$lte': '$lte',
236
+ '$in': '$in',
237
+ '$nin': '$nin', // Special handling with $not/$in
238
+ '$regex': '$regexMatch', // Use aggregation regex operator
239
+ '$exists': '$exists' // Special handling with $type or similar
240
+ };
241
+
242
+ // Helper to build the nested $find structure
243
+ function buildNestedFindStructure(pathSegments, finalPayload) {
244
+ // Base case: If no segments left, return the final condition payload
245
+ if (!pathSegments || pathSegments.length === 0) {
246
+ return finalPayload;
247
+ }
248
+
249
+ const currentSegment = pathSegments[0];
250
+ const remainingSegments = pathSegments.slice(1);
251
+
252
+ // Recursively build the structure
253
+ return {
254
+ [currentSegment]: {
255
+ '$find': buildNestedFindStructure(remainingSegments, finalPayload)
256
+ }
257
+ };
258
+ }
259
+ // ... (imports and other functions) ...
260
+
261
+ /**
262
+ * Builds the core condition payload (operators and values, or logical groups)
263
+ * from a ConditionBuilder condition object or sub-object.
264
+ * It handles the transformation of simple conditions and logical groups into
265
+ * the format expected by the API filter, using $expr for comparisons and $find for nesting.
266
+ *
267
+ * @param {object} conditionNode - A node from the ConditionBuilder structure.
268
+ * @returns {object | null} The condition payload object or null if invalid.
269
+ */
270
+ function buildApiConditionPayloadRecursive(conditionNode) {
271
+ if (!conditionNode || typeof conditionNode !== 'object') {
272
+ console.warn("Invalid node in buildApiConditionPayloadRecursive:", conditionNode);
273
+ return null;
274
+ }
275
+
276
+ // --- Handle Logical Operators ($and, $or) ---
277
+ if (conditionNode.$and && Array.isArray(conditionNode.$and)) {
278
+ const subPayloads = conditionNode.$and.map(buildApiConditionPayloadRecursive).filter(p => p !== null);
279
+ if (subPayloads.length === 0) return null;
280
+ if (subPayloads.length === 1) return subPayloads[0];
281
+ return { '$and': subPayloads };
282
+ }
283
+ if (conditionNode.$or && Array.isArray(conditionNode.$or)) {
284
+ const subPayloads = conditionNode.$or.map(buildApiConditionPayloadRecursive).filter(p => p !== null);
285
+ if (subPayloads.length === 0) return null;
286
+ if (subPayloads.length === 1) return subPayloads[0];
287
+ return { '$or': subPayloads };
288
+ }
289
+
290
+ // --- Handle Simple Condition { model?, path, op, value } ---
291
+ if (conditionNode.path && Array.isArray(conditionNode.path) && conditionNode.op) {
292
+ const path = conditionNode.path;
293
+
294
+ if (path.length === 0) {
295
+ console.warn("Simple condition node has empty path:", conditionNode);
296
+ return null;
297
+ }
298
+
299
+ // --- Build the core $expr payload ---
300
+ const finalFieldName = path[path.length - 1];
301
+ const segmentsForNesting = path.slice(0, -1); // Path segments before the last one
302
+
303
+ // ***** CORRECTED LOGIC *****
304
+ // Determine the field path for the $expr based on nesting level
305
+ const fieldPathForExpr = segmentsForNesting.length === 0
306
+ ? `$${finalFieldName}` // Top-level field: $fieldName
307
+ : `$$this.${finalFieldName}`; // Nested field (inside $find): $$this.fieldName
308
+ // ***** END CORRECTION *****
309
+
310
+ const operator = conditionNode.op;
311
+ const value = conditionNode.value;
312
+ const exprOperator = operatorToExprOperatorMap[operator];
313
+
314
+ let exprPayload; // This will hold the { $operator: [ field, value ] } part
315
+
316
+ if (!exprOperator) {
317
+ console.warn(`Unsupported operator for $expr conversion: ${operator}`);
318
+ return null;
319
+ }
320
+
321
+ // --- Build the specific $expr based on the operator ---
322
+ switch (exprOperator) {
323
+ case '$regex': // Ajout pour gérer l'alias
324
+ case '$nin':
325
+ const ninValueArray = Array.isArray(value) ? value : String(value).split(',').map(s => s.trim()).filter(Boolean);
326
+ // Note: $nin at the top level is often better handled *outside* $expr
327
+ // but for consistency with $find, we keep it inside $expr here.
328
+ // If segmentsForNesting.length === 0, a standard query { field: { $nin: [...] } } might be more efficient.
329
+ exprPayload = { '$not': { '$in': [fieldPathForExpr, ninValueArray] } };
330
+ break;
331
+ case '$in':
332
+ const inValueArray = Array.isArray(value) ? value : String(value).split(',').map(s => s.trim()).filter(Boolean);
333
+ // Similar note as $nin regarding top-level efficiency.
334
+ exprPayload = { '$in': [fieldPathForExpr, inValueArray] };
335
+ break;
336
+ case '$regexMatch':
337
+ exprPayload = {
338
+ '$regexMatch': {
339
+ input: fieldPathForExpr,
340
+ regex: String(value),
341
+ options: 'i'
342
+ }
343
+ };
344
+ break;
345
+ case '$exists':
346
+ const shouldExist = typeof value === 'boolean' ? value : String(value).toLowerCase() === 'true';
347
+ // Using $ne/$eq with $type is a common way to check existence in $expr
348
+ exprPayload = {
349
+ [shouldExist ? '$ne' : '$eq']: [ { $type: fieldPathForExpr }, "undefined" ]
350
+ };
351
+ // Note: For top-level, { field: { $exists: true/false } } is standard.
352
+ break;
353
+ default:
354
+ // Standard binary operators ($eq, $ne, $gt, $gte, $lt, $lte)
355
+ let processedValue = value;
356
+ // Basic type guessing for common string values
357
+ if (operator === '$eq' || operator === '$ne') {
358
+ if (value === 'true') processedValue = true;
359
+ else if (value === 'false') processedValue = false;
360
+ else if (value === 'null') processedValue = null;
361
+ // Avoid automatic number conversion for eq/ne unless explicitly needed
362
+ } else if (['$gt', '$gte', '$lt', '$lte'].includes(operator)) {
363
+ // Attempt number conversion for comparison operators
364
+ const num = Number(value);
365
+ if (!isNaN(num) && String(value).trim() !== '') {
366
+ processedValue = num;
367
+ }
368
+ // TODO: Consider adding date conversion if field type is known
369
+ }
370
+ exprPayload = { [exprOperator]: [fieldPathForExpr, processedValue] };
371
+ break;
372
+ }
373
+
374
+ // The final payload for the innermost level is the $expr object
375
+ const corePayload = exprPayload;
376
+
377
+ // --- Structure the filter using nested $find based on the path segments before the last one ---
378
+ return buildNestedFindStructure(segmentsForNesting, corePayload);
379
+ }
380
+
381
+ // Log warning for unknown structures
382
+ console.warn("Unknown structure encountered in buildApiConditionPayloadRecursive:", conditionNode);
383
+ return null;
384
+ }
385
+
386
+
387
+ /**
388
+ * Transforms a ConditionBuilder condition object into the specific filter format
389
+ * for the /api/data/search endpoint.
390
+ * The final format is { filter: { ... } }.
391
+ *
392
+ * @param {object | null | undefined} condition - The condition object from ConditionBuilder.
393
+ * @returns {object} The filter object in the format { filter: { ... } }.
394
+ * Returns { filter: {} } if the condition is invalid/empty.
395
+ */
396
+ export function conditionToApiSearchFilter(condition) {
397
+ // Handle null or invalid input condition
398
+ if (!condition || typeof condition !== 'object') {
399
+ return { filter: {} }; // Return empty filter for invalid input
400
+ }
401
+
402
+ // Build the main filter content using the recursive payload builder
403
+ const filterContent = buildApiConditionPayloadRecursive(condition);
404
+
405
+ // Return the final structure, ensuring filterContent is not null
406
+ return { filter: filterContent || {} };
407
+ }
408
+
409
+
410
+ export const getFieldValueHash = (model, data) => {
411
+ // Prioritize composite unique constraints if they exist
412
+ const uniqueConstraints = model.constraints?.filter(c => c.type === 'unique') || [];
413
+
414
+ if (uniqueConstraints.length > 0) {
415
+ // Combine keys from all unique constraints to create a truly unique hash.
416
+ const constraintKeys = [...new Set(uniqueConstraints.flatMap(c => c.keys))];
417
+ return getObjectHash(data, constraintKeys, 'constraint');
418
+ }
419
+ const uniqueFields = model.fields.filter(f => f.unique).map(m => m.name);
420
+ const fs = model.fields.map(m => m.name);
421
+ const fields = ['_model', '_user', ...(uniqueFields.length ? uniqueFields : fs)];
422
+ return getObjectHash(data, fields);
423
+ }
424
+
425
+
426
+ /**
427
+ * Obtient une représentation textuelle lisible d'un enregistrement de données.
428
+ * Gère les relations de manière récursive pour afficher des informations pertinentes.
429
+ *
430
+ * @param {object} model - La définition du modèle pour la `data` actuelle.
431
+ * @param {object} data - L'enregistrement de données à convertir en chaîne de caractères.
432
+ * @param {function} t - La fonction de traduction i18next.
433
+ * @param {array} allModels - Un tableau contenant les définitions de TOUS les modèles du système.
434
+ * @returns {string} - La chaîne de caractères représentant la donnée.
435
+ */
436
+ export const getDataAsString = (model, data, tr, allModels, extended=false) => {
437
+ const { t, i18n} = tr;
438
+ const lang = (i18n.resolvedLanguage || i18n.language).split(/[-_]/)?.[0];
439
+ // Cas de base : si le modèle ou les données sont manquants, on ne peut rien faire.
440
+ if (!model || !data) {
441
+ return '';
442
+ }
443
+
444
+ // 1. Déterminer les champs à afficher
445
+ // On privilégie les champs marqués comme "asMain"
446
+ let parts;
447
+ if( extended )
448
+ parts = model.fields.map(m => m.name);
449
+ else
450
+ parts = model.fields.filter(f => f.asMain).map(m => m.name);
451
+
452
+ // 2. Si aucun champ "asMain", on cherche des champs standards (nom, titre, etc.)
453
+ if (!parts.length) {
454
+ for (const main of mainFieldsTypes) {
455
+ const f = model.fields?.find((f) => f.type === main)?.name;
456
+ if (f) {
457
+ parts.push(f);
458
+ break; // On ne prend que le premier trouvé
459
+ }
460
+ }
461
+ }
462
+
463
+ // 3. Si toujours rien, on se rabat sur l'_id
464
+ if (!parts.length) {
465
+ parts.push('_id');
466
+ }
467
+
468
+ // 4. Construire la chaîne de caractères finale
469
+ const resultString = parts.map(fieldName => {
470
+ const fieldDef = model.fields.find(f => f.name === fieldName);
471
+ const value = data[fieldName];
472
+
473
+ if (!fieldDef || value === null || value === undefined) {
474
+ return null; // Ignore les champs ou valeurs non définis
475
+ }
476
+
477
+ // --- NOUVELLE LOGIQUE POUR LES RELATIONS ---
478
+ if (fieldDef.type === 'relation' && value) {
479
+ const relatedModel = allModels?.find(m => m.name === fieldDef.relation);
480
+ if (!relatedModel) return `[${fieldDef.relation}]`; // Modnon trouvé
481
+
482
+ // Si la relation est multiple (un tableau d'objets)
483
+ if (Array.isArray(value)) {
484
+ return value
485
+ .map(item => getDataAsString(relatedModel, item, t, allModels)) // Appel récursif pour chaque item
486
+ .filter(Boolean)
487
+ .join(', ');
488
+ }
489
+ // Si la relation est simple (un seul objet)
490
+ else if (typeof value === 'object') {
491
+ return getDataAsString(relatedModel, value, t, allModels); // Appel récursif
492
+ }
493
+ }
494
+
495
+ if(fieldDef.type === 'datetime'){
496
+ return new Date(value).toLocaleDateString(lang, {year: 'numeric', month: 'numeric', day: 'numeric', hour: 'numeric', minute: 'numeric'});
497
+ }
498
+ if(fieldDef.type === 'date'){
499
+ return new Date(value).toLocaleDateString(lang, {year: 'numeric', month: 'numeric', day: 'numeric'});
500
+ }
501
+ // --- FIN DE LA NOUVELLE LOGIQUE ---
502
+
503
+ // Logique existante pour les autres types de champs
504
+ if (value.value !== undefined) {
505
+ return value.value; // Champs traduits
506
+ }
507
+
508
+ if( typeof(value) === 'string' ) {
509
+ const translatedValue = t(value, {defaultValue: value});
510
+ return translatedValue || value.toString();
511
+ }
512
+ if( typeof(value) === 'object' ) {
513
+ return JSON.stringify(value, null, 2);
514
+ }
515
+ return value;
516
+
517
+ }).filter(Boolean).join(', ');
518
+
519
+ // Si après tout ça la chaîne est vide (ex: l'ID est le seul champ mais on ne veut pas l'afficher seul)
520
+ // On retourne l'ID pour avoir au moins un identifiant.
521
+ return resultString || data._id;
521
522
  }