data-primals-engine 1.0.6 → 1.0.8

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.
@@ -365,45 +365,40 @@ async function handleCreateDataAction(actionDef, contextData, user, dbCollection
365
365
 
366
366
  try {
367
367
  // 2. Substitute Variables in the data template
368
- let substitutedDataString;
369
368
  let dataObject;
370
369
 
371
- // dataToCreate might be a string (JSON) or already an object from the model definition
372
370
  if (typeof dataToCreate === 'string') {
373
- substitutedDataString = await substituteVariables(dataToCreate, contextData, user);
374
- console.log({substitutedDataString});
371
+ const substitutedDataString = await substituteVariables(dataToCreate, contextData, user);
372
+ try {
373
+ // CORRECTION : Utiliser la bonne variable (substitutedDataString)
374
+ dataObject = JSON.parse(substitutedDataString);
375
+ } catch (parseError) {
376
+ const msg = `Failed to parse substituted JSON string: ${substitutedDataString}. Error: ${parseError.message}`;
377
+ logger.error(`[handleCreateDataAction] ${msg}`);
378
+ return { success: false, message: msg };
379
+ }
375
380
  } else if (typeof dataToCreate === 'object') {
376
- // If it's an object, substitute within its values
377
- const substitutedObject = await substituteVariables(dataToCreate, contextData, user);
378
- // We still need to stringify and parse if it was originally intended as JSON string
379
- // Or assume it's meant to be used directly if it came as an object
380
- // Let's assume direct use for now if it's an object in the definition
381
- dataObject = substitutedObject;
382
-
383
- console.log({substitutedObject});
381
+ // CORRECTION : Assigner le résultat de la substitution à dataObject.
382
+ // On passe une copie pour ne pas muter le template original.
383
+ dataObject = await substituteVariables(JSON.parse(JSON.stringify(dataToCreate)), contextData, user);
384
384
  } else {
385
- const msg = `[handleCreateDataAction] Action ${actionDef.name} (${actionDef._id}): 'dataToCreate' has an invalid type (${typeof dataToCreate}). Expected string (JSON) or object.`;
385
+ const msg = `[handleCreateDataAction] 'dataToCreate' has an invalid type (${typeof dataToCreate}). Expected string (JSON) or object.`;
386
386
  logger.error(msg);
387
387
  return { success: false, message: msg };
388
388
  }
389
389
 
390
+ // Log pour débogage
391
+ logger.debug('Final data object after substitution:', dataObject);
390
392
 
391
- // 3. Parse the substituted data if it was a string
392
- if (substitutedDataString) {
393
- try {
394
- dataObject = JSON.parse(substitutedDataString);
395
- if (typeof dataObject !== 'object' || dataObject === null) {
396
- throw new Error("Parsed data is not a valid object.");
397
- }
398
- } catch (parseError) {
399
- const msg = `[handleCreateDataAction] Action ${actionDef.name} (${actionDef._id}): Failed to parse substituted 'dataToCreate' JSON. Error: ${parseError.message}. Substituted string: ${substitutedDataString}`;
400
- logger.error(msg);
401
- return { success: false, message: msg };
402
- }
403
- }
393
+ // 3. Appeler insertData avec l'objet correctement substitué
394
+ const result = await insertData(targetModel, dataObject, [], user, true, true); // On attend la fin du workflow déclenché par cette création
404
395
 
405
- const ids = await insertData(targetModel, dataObject, [], user, false);
406
- return { success: true, insertedIds: ids };
396
+ if (result.success) {
397
+ return { success: true, insertedIds: result.insertedIds };
398
+ } else {
399
+ // Propage l'erreur venant de insertData
400
+ return { success: false, message: result.error || "Insertion failed." };
401
+ }
407
402
 
408
403
  } catch (error) {
409
404
  const msg = `[handleCreateDataAction] Action ${actionDef.name} (${actionDef._id}): Unexpected error during creation for model '${targetModel}'. Error: ${error.message}`;
@@ -723,9 +718,124 @@ function getNestedValue(obj, path) {
723
718
  // Retourne la valeur finale trouvée
724
719
  return current;
725
720
  }
721
+
722
+ /**
723
+ * Résout un chemin de variable complexe (ex: "triggerData.order.customer.contact.email")
724
+ * en construisant un pipeline d'agrégation dynamique pour tout récupérer en une seule requête.
725
+ *
726
+ * @param {string} pathString - Le chemin de la variable, ex: "triggerData.order.customer.contact.email".
727
+ * @param {object} initialContext - L'objet de départ (le triggerData).
728
+ * @param {object} user - L'objet utilisateur pour les requêtes DB.
729
+ * @returns {Promise<any>} La valeur résolue.
730
+ */
731
+ async function resolvePathValue(pathString, initialContext, user) {
732
+ const pathParts = pathString.split('.');
733
+ const rootObjectKey = pathParts.shift(); // ex: "triggerData"
734
+
735
+ // Si le chemin ne commence pas par triggerData ou context, essayer de résoudre directement
736
+ if (rootObjectKey !== 'triggerData' && rootObjectKey !== 'context') {
737
+ let current = initialContext;
738
+ for (const part of [rootObjectKey, ...pathParts]) {
739
+ if (current === null || typeof current === 'undefined') return undefined;
740
+ current = current[part];
741
+ }
742
+ return current;
743
+ }
744
+
745
+ // Vérifier si c'est un chemin simple qui peut être résolu sans aggregation
746
+ if (pathParts.length === 1) {
747
+ return initialContext[pathParts[0]];
748
+ }
749
+
750
+ let currentModelName = initialContext._model;
751
+ let currentDocId = new ObjectId(initialContext._id);
752
+ const collection = getCollectionForUser(user);
753
+
754
+ // Construire le pipeline d'agrégation
755
+ const pipeline = [
756
+ { $match: { _id: currentDocId } }
757
+ ];
758
+
759
+ // Itérer sur chaque segment du chemin pour construire les lookups
760
+ for (let i = 0; i < pathParts.length; i++) {
761
+ const segment = pathParts[i];
762
+
763
+ // Si c'est le dernier segment, on n'a pas besoin de faire un lookup
764
+ if (i === pathParts.length - 1) break;
765
+
766
+ const modelDef = await getModel(currentModelName, user);
767
+ const fieldDef = modelDef.fields.find(f => f.name === segment);
768
+
769
+ if (!fieldDef || fieldDef.type !== 'relation') {
770
+ // Si ce n'est pas une relation, on ne peut pas continuer le chemin
771
+ return undefined;
772
+ }
773
+
774
+ const nextModelName = fieldDef.relation;
775
+ const asField = `__resolved_${segment}`;
776
+
777
+ pipeline.push({
778
+ $lookup: {
779
+ from: collection.collectionName,
780
+ let: { relationId: `$${segment}` },
781
+ pipeline: [
782
+ {
783
+ $match: {
784
+ $expr: {
785
+ $eq: ["$_id", {
786
+ $cond: {
787
+ if: { $eq: [{ $type: "$$relationId" }, "string"] },
788
+ then: { $toObjectId: "$$relationId" },
789
+ else: "$$relationId"
790
+ }
791
+ }]
792
+ }
793
+ }
794
+ }
795
+ ],
796
+ as: asField
797
+ }
798
+ });
799
+
800
+ pipeline.push({
801
+ $unwind: {
802
+ path: `$${asField}`,
803
+ preserveNullAndEmptyArrays: true
804
+ }
805
+ });
806
+
807
+ pipeline.push({
808
+ $addFields: {
809
+ [segment]: `$${asField}`
810
+ }
811
+ });
812
+
813
+ pipeline.push({ $project: { [asField]: 0 } });
814
+
815
+ currentModelName = nextModelName;
816
+ }
817
+
818
+ const results = await collection.aggregate(pipeline).toArray();
819
+
820
+ if (results.length === 0) {
821
+ return undefined;
822
+ }
823
+
824
+ // Extraire la valeur finale
825
+ let finalValue = results[0];
826
+ for (const part of pathParts) {
827
+ if (finalValue === null || typeof finalValue === 'undefined') {
828
+ return undefined;
829
+ }
830
+ finalValue = finalValue[part];
831
+ }
832
+
833
+ return finalValue;
834
+ }
835
+
726
836
  /**
727
837
  * Remplace les placeholders dans un template (string, object, array) par des valeurs du contextData.
728
- * Mise à jour pour une meilleure gestion des types et des placeholders introuvables.
838
+ * Version améliorée avec support des chemins complexes via resolvePathValue.
729
839
  */
730
840
  export async function substituteVariables(template, contextData, user) {
731
841
  // 1. Retourner les types non substituables tels quels
@@ -759,54 +869,76 @@ export async function substituteVariables(template, contextData, user) {
759
869
  // `contextToSearch` contient toutes les données disponibles à sa racine
760
870
  const contextToSearch = { ...contextData, env: userEnv };
761
871
 
762
- // 5. Logique de résolution de valeur unifiée
763
- const findValue = (key) => {
764
- let value;
872
+ // 5. Logique de résolution de valeur améliorée avec resolvePathValue
873
+ const findValue = async (key) => {
765
874
  let path = key.trim();
766
-
767
- // Gérer les raccourcis : {triggerData.field} -> {context.triggerData.field}
768
- if (path.startsWith('triggerData.')) {
769
- path = 'context.' + path;
875
+ if (path.endsWith('._id')) {
876
+ const basePath = path.slice(0, -4);
877
+ const value = await findValue(basePath);
878
+ return value?._id?.toString(); // Convertit l'ObjectId en string
770
879
  }
771
- // Note : Pas de gestion spéciale pour {env.*} car `env` est déjà à la racine de contextToSearch.
772
880
 
773
881
  // Gérer les valeurs dynamiques spéciales
774
882
  if (path === 'now') {
775
- value = new Date().toISOString();
883
+ return new Date().toISOString();
776
884
  } else if (path === 'randomUUID') {
777
- // Suppose que 'crypto' est disponible dans le scope (ex: Node.js >= 19 ou avec import 'node:crypto')
778
- value = crypto.randomUUID();
779
- } else {
780
- // Chercher le chemin dans l'objet de contexte entier
781
- value = getNestedValue(contextToSearch, path);
885
+ return crypto.randomUUID();
782
886
  }
783
- return value;
887
+
888
+ // Détecter si le chemin est complexe (contient plus d'un point)
889
+ if (path.split('.').length > 1) {
890
+ try {
891
+ // Essayer de résoudre le chemin avec resolvePathValue
892
+ const [root, ...rest] = path.split('.');
893
+ // On vérifie si la racine du chemin (ex: 'triggerData') existe dans notre contexte
894
+ if (contextToSearch[root]) {
895
+ const resolvedValue = await resolvePathValue(
896
+ rest.join('.'),
897
+ contextToSearch[root], // On passe le bon objet de départ (ex: l'objet triggerData)
898
+ user
899
+ );
900
+ if (resolvedValue !== undefined) {
901
+ return resolvedValue;
902
+ }
903
+ }
904
+ } catch (error) {
905
+ console.warn(`Erreur lors de la résolution du chemin "${path}":`, error.message);
906
+ // On continue avec la méthode normale si la résolution échoue
907
+ }
908
+ }
909
+
910
+ // Fallback: chercher le chemin dans l'objet de contexte normal
911
+ return getNestedValue(contextToSearch, path);
784
912
  };
785
913
 
786
914
  // CAS A : La chaîne est un unique placeholder (ex: "{context.triggerData.product.price}")
787
- // Ceci préserve le type de donnée original (nombre, booléen, objet, etc.).
788
915
  const singlePlaceholderMatch = template.match(/^\{([^}]+)\}$/);
789
916
  if (singlePlaceholderMatch) {
790
917
  const key = singlePlaceholderMatch[1];
791
- const value = findValue(key);
792
- // Si une valeur est trouvée, la retourner directement. Sinon, retourner le template original.
918
+ const value = await findValue(key);
793
919
  return value !== undefined ? value : template;
794
920
  }
795
921
 
796
- // CAS B : La chaîne est complexe (ex: "Produit: {context.triggerData.product.name}!")
797
- // Le résultat sera toujours une chaîne de caractères.
922
+ // CAS B : La chaîne contient plusieurs placeholders ou mix texte/variables
798
923
  const placeholderRegex = /\{([^}]+)\}/g;
799
- return template.replace(placeholderRegex, (match, key) => {
800
- const value = findValue(key);
924
+ const placeholders = [...template.matchAll(placeholderRegex)];
801
925
 
802
- if (value !== undefined) {
803
- if (value === null) return 'null'; // Convertir explicitement null en chaîne 'null'
804
- if (typeof value === 'object') return JSON.stringify(value); // Convertir les objets/tableaux en JSON
805
- return String(value); // S'assurer que les autres types sont convertis en chaîne
806
- }
926
+ // Si aucun placeholder trouvé, retourner la chaîne telle quelle
927
+ if (placeholders.length === 0) {
928
+ return template;
929
+ }
807
930
 
808
- return match; // Placeholder non trouvé, le laisser tel quel
809
- });
931
+ // Remplacer chaque placeholder de manière asynchrone
932
+ let result = template;
933
+ for (const [match, key] of placeholders) {
934
+ const value = await findValue(key);
935
+ const replacement = value !== undefined
936
+ ? (value === null ? 'null' : typeof value === 'object' ? JSON.stringify(value) : String(value))
937
+ : match;
938
+ result = result.replace(match, replacement);
939
+ }
940
+
941
+ return result;
810
942
  }
811
943
 
812
944
  /**
package/src/packs.js CHANGED
@@ -242,20 +242,17 @@ export const getAllPacks = async () => {
242
242
  },{
243
243
  "name": "Data purging",
244
244
  "startStep": { "$link": { "name": "Purge execution", "_model": "workflowStep" } }
245
+ },{
246
+ "name": "Shipment Notification",
247
+ "description": "Notifies the customer when their order has been shipped.",
248
+ "startStep": { "$link": { "name": "Send Shipment Email Step", "_model": "workflowStep" } }
245
249
  }],
246
250
  "workflowAction": [
247
- { "name": "Update order status to 'processing'", "type": "UpdateData", "targetModel": "order", "targetSelector": { "_id": "{triggerData._id}" }, "fieldsToUpdate": { "status": "processing" } },
251
+ { "name": "Update order status to 'processing'", "type": "UpdateData", "targetModel": "order", "targetSelector": { "_id": { $toObjectId: "{triggerData._id}" }}, "fieldsToUpdate": { "status": "processing" } },
248
252
  { "name": "Create Shipment Record", "type": "CreateData",
249
253
  "targetModel": "shipment",
250
254
  "dataToCreate": { "order": "{triggerData._id}", "status": "preparing" } },
251
- { "name": "Update order status to 'shipped'", "type": "UpdateData", "targetModel": "order", "targetSelector": { "_id": "{triggerData._id}" }, "fieldsToUpdate": { "status": "shipped" } },
252
- {
253
- "name": "Send Shipping Confirmation",
254
- "type": "SendEmail",
255
- "emailRecipients": ["{triggerData.customer.email}"],
256
- "emailSubject": "Your order #{triggerData.orderId} has been shipped!",
257
- "emailContent": "Hello {triggerData.customer.firstName},<br><br>Good news! Your order is on its way. You can track it using this number: {context.CreateShipmentRecord.insertedIds[0]}.<br><br>Thank you for your purchase!"
258
- },
255
+ { "name": "Update order status to 'shipped'", "type": "UpdateData", "targetModel": "order", "targetSelector": { "_id": { $toObjectId: "{triggerData._id}" }}, "fieldsToUpdate": { "status": "shipped" } },
259
256
  {
260
257
  name: 'Delete queries older than 30 days',
261
258
  type: 'DeleteData',
@@ -263,6 +260,14 @@ export const getAllPacks = async () => {
263
260
  targetSelector: {
264
261
  "$lt": ["$timestamp", {"$subtract": ["$$NOW", 1000*3600*24*365*5] } ]
265
262
  }
263
+ },
264
+ {
265
+ "name": "Send Shipping Notification Email",
266
+ "type": "SendEmail",
267
+ // C'est ici que la magie opère !
268
+ "emailRecipients": ["{triggerData.order.customer.contact.email}"],
269
+ "emailSubject": "Your order #{triggerData.order.orderId} has been shipped!",
270
+ "emailContent": "Hello {triggerData.order.customer.contact.firstName},<br><br>Good news! Your order #{triggerData.order.orderId} is on its way. You can track it using this number: <strong>{triggerData.trackingNumber}</strong>.<br><br>Thank you for your purchase!"
266
271
  }
267
272
  ],
268
273
  "workflowStep": [
@@ -278,6 +283,12 @@ export const getAllPacks = async () => {
278
283
  "actions": { "$link": { "name": "Create Shipment Record", "_model": "workflowAction" } },
279
284
  "onSuccessStep": { "$link": { "name": "Ship Order", "_model": "workflowStep" } },
280
285
  },
286
+ {
287
+ "name": "Send Shipment Email Step",
288
+ "workflow": { "$link": { "name": "Shipment Notification", "_model": "workflow" } },
289
+ "actions": { "$link": { "name": "Send Shipping Notification Email", "_model": "workflowAction" } },
290
+ "isTerminal": true
291
+ },
281
292
  {
282
293
  "name": "Ship Order",
283
294
  "workflow": { "$link": { "name": "Order Fulfillment", "_model": "workflow" }},
@@ -300,7 +311,12 @@ export const getAllPacks = async () => {
300
311
  }
301
312
  ],
302
313
  "workflowTrigger": [{
303
- "name": "New order created", "workflow": { "$link": { "name": "Order Fulfillment", "_model": "workflow" } }, "type": "manual", "onEvent": "DataAdded", "dataFilter":{}, "targetModel": "order", "isActive": true
314
+ "name": "On New Shipment Created",
315
+ "workflow": { "$link": { "name": "Shipment Notification", "_model": "workflow" } },
316
+ "type": "manual", // Déclenché par un événement
317
+ "onEvent": "DataAdded",
318
+ "targetModel": "shipment",
319
+ "isActive": true
304
320
  },{
305
321
  name: 'Daily data purge',
306
322
  type: 'scheduled',